mirror of
https://github.com/alam00000/bentopdf.git
synced 2026-11-05 11:52:48 +01:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
53bcf09566 | ||
|
|
d2dc459ba9 | ||
|
|
71ced499e7 | ||
|
|
fc264f796a | ||
|
|
e55c2ed405 | ||
|
|
4b875a638c | ||
|
|
b71d6c9b96 | ||
|
|
d63d1683c7 | ||
|
|
0347013df1 | ||
|
|
412f2f1acb | ||
|
|
a471bbcf4d | ||
|
|
e1843e17b3 | ||
|
|
ac3ad68b56 | ||
|
|
745698a5ba | ||
|
|
dc1dbb036c | ||
|
|
175c52bb1f | ||
|
|
1d9b7c2a07 | ||
|
|
1534ce4d85 | ||
|
|
3cab1d37c7 |
@@ -13,10 +13,6 @@
|
||||
|
||||

|
||||
|
||||
### FOSS Hack 2026
|
||||
|
||||
[](https://drive.google.com/file/d/14Vf62PvHiuf1RKFKtMlAzpbf4QQPLpRF/view?usp=sharing)
|
||||
|
||||
---
|
||||
|
||||
## Table of Contents
|
||||
|
||||
+15
-1
@@ -33,6 +33,13 @@ BentoPDF uses **i18next** for internationalization (i18n). Currently supported l
|
||||
- **Traditional Chinese (Taiwan)** (`zh-TW`)
|
||||
- **Korean** (`ko`)
|
||||
- **Russian** (`ru`)
|
||||
- **Ukrainian** (`uk`)
|
||||
- **Slovak** (`sk`)
|
||||
- **Arabic** (`ar`)
|
||||
- **Dutch** (`nl`)
|
||||
- **Danish** (`da`)
|
||||
- **Swedish** (`sv`)
|
||||
- **Japanese** (`ja`)
|
||||
|
||||
The app automatically detects the language from the URL path:
|
||||
|
||||
@@ -603,8 +610,15 @@ Current translation coverage:
|
||||
| Traditional Chinese | `zh-TW` | ✅ Complete | Community |
|
||||
| Korean | `ko` | ✅ Complete | Community |
|
||||
| Russian | `ru` | ✅ Complete | Community |
|
||||
| Ukrainian | `uk` | ✅ Complete | Community |
|
||||
| Slovak | `sk` | ✅ Complete | Community |
|
||||
| Arabic | `ar` | ✅ Complete | Community |
|
||||
| Dutch | `nl` | ✅ Complete | Community |
|
||||
| Danish | `da` | ✅ Complete | Community |
|
||||
| Swedish | `sv` | ✅ Complete | Community |
|
||||
| Japanese | `ja` | ✅ Complete | Community |
|
||||
| Your Language | `??` | 🚧 In Progress | You? |
|
||||
|
||||
---
|
||||
|
||||
**Last Updated**: January 2026
|
||||
**Last Updated**: June 2026
|
||||
|
||||
+2
-2
@@ -7,10 +7,10 @@ type: application
|
||||
# This is the chart version. This version number should be incremented each time you make changes
|
||||
# to the chart and its templates, including the app version.
|
||||
# Versions are expected to follow Semantic Versioning (https://semver.org/)
|
||||
version: 1.0.4
|
||||
version: 1.0.5
|
||||
|
||||
# This is the version number of the application being deployed. This version number should be
|
||||
# incremented each time you make changes to the application. Versions are not expected to
|
||||
# follow Semantic Versioning. They should reflect the version the application is using.
|
||||
# It is recommended to use it with quotes.
|
||||
appVersion: '2.8.5'
|
||||
appVersion: '2.8.6'
|
||||
|
||||
Generated
+84
-84
@@ -104,7 +104,7 @@
|
||||
"prettier": "^3.8.1",
|
||||
"typescript": "~5.9.3",
|
||||
"typescript-eslint": "^8.56.1",
|
||||
"vite": "^8.0.9",
|
||||
"vite": "^8.0.10",
|
||||
"vite-plugin-compression": "^0.5.1",
|
||||
"vite-plugin-handlebars": "^2.0.0",
|
||||
"vite-plugin-node-polyfills": "^0.26.0",
|
||||
@@ -1284,9 +1284,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@emnapi/core": {
|
||||
"version": "1.9.2",
|
||||
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.9.2.tgz",
|
||||
"integrity": "sha512-UC+ZhH3XtczQYfOlu3lNEkdW/p4dsJ1r/bP7H8+rhao3TTTMO1ATq/4DdIi23XuGoFY+Cz0JmCbdVl0hz9jZcA==",
|
||||
"version": "1.10.0",
|
||||
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz",
|
||||
"integrity": "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
@@ -1302,9 +1302,9 @@
|
||||
"optional": true
|
||||
},
|
||||
"node_modules/@emnapi/runtime": {
|
||||
"version": "1.9.2",
|
||||
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.9.2.tgz",
|
||||
"integrity": "sha512-3U4+MIWHImeyu1wnmVygh5WlgfYDtyf0k8AbLhMFxOipihf6nrWC4syIm/SwEeec0mNSafiiNnMJwbza/Is6Lw==",
|
||||
"version": "1.10.0",
|
||||
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz",
|
||||
"integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
@@ -2049,9 +2049,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@oxc-project/types": {
|
||||
"version": "0.126.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.126.0.tgz",
|
||||
"integrity": "sha512-oGfVtjAgwQVVpfBrbtk4e1XDyWHRFta6BS3GWVzrF8xYBT2VGQAk39yJS/wFSMrZqoiCU4oghT3Ch0HaHGIHcQ==",
|
||||
"version": "0.127.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.127.0.tgz",
|
||||
"integrity": "sha512-aIYXQBo4lCbO4z0R3FHeucQHpF46l2LbMdxRvqvuRuW2OxdnSkcng5B8+K12spgLDj93rtN3+J2Vac/TIO+ciQ==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/Boshen"
|
||||
@@ -2120,9 +2120,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-android-arm64": {
|
||||
"version": "1.0.0-rc.16",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0-rc.16.tgz",
|
||||
"integrity": "sha512-rhY3k7Bsae9qQfOtph2Pm2jZEA+s8Gmjoz4hhmx70K9iMQ/ddeae+xhRQcM5IuVx5ry1+bGfkvMn7D6MJggVSA==",
|
||||
"version": "1.0.0-rc.17",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0-rc.17.tgz",
|
||||
"integrity": "sha512-s70pVGhw4zqGeFnXWvAzJDlvxhlRollagdCCKRgOsgUOH3N1l0LIxf83AtGzmb5SiVM4Hjl5HyarMRfdfj3DaQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -2136,9 +2136,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-darwin-arm64": {
|
||||
"version": "1.0.0-rc.16",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.0-rc.16.tgz",
|
||||
"integrity": "sha512-rNz0yK078yrNn3DrdgN+PKiMOW8HfQ92jQiXxwX8yW899ayV00MLVdaCNeVBhG/TbH3ouYVObo8/yrkiectkcQ==",
|
||||
"version": "1.0.0-rc.17",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.0-rc.17.tgz",
|
||||
"integrity": "sha512-4ksWc9n0mhlZpZ9PMZgTGjeOPRu8MB1Z3Tz0Mo02eWfWCHMW1zN82Qz/pL/rC+yQa+8ZnutMF0JjJe7PjwasYw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -2152,9 +2152,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-darwin-x64": {
|
||||
"version": "1.0.0-rc.16",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.0-rc.16.tgz",
|
||||
"integrity": "sha512-r/OmdR00HmD4i79Z//xO06uEPOq5hRXdhw7nzkxQxwSavs3PSHa1ijntdpOiZ2mzOQ3fVVu8C1M19FoNM+dMUQ==",
|
||||
"version": "1.0.0-rc.17",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.0-rc.17.tgz",
|
||||
"integrity": "sha512-SUSDOI6WwUVNcWxd02QEBjLdY1VPHvlEkw6T/8nYG322iYWCTxRb1vzk4E+mWWYehTp7ERibq54LSJGjmouOsw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -2168,9 +2168,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-freebsd-x64": {
|
||||
"version": "1.0.0-rc.16",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.0-rc.16.tgz",
|
||||
"integrity": "sha512-KcRE5w8h0OnjUatG8pldyD14/CQ5Phs1oxfR+3pKDjboHRo9+MkqQaiIZlZRpsxC15paeXme/I127tUa9TXJ6g==",
|
||||
"version": "1.0.0-rc.17",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.0-rc.17.tgz",
|
||||
"integrity": "sha512-hwnz3nw9dbJ05EDO/PvcjaaewqqDy7Y1rn1UO81l8iIK1GjenME75dl16ajbvSSMfv66WXSRCYKIqfgq2KCfxw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -2184,9 +2184,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-linux-arm-gnueabihf": {
|
||||
"version": "1.0.0-rc.16",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.0-rc.16.tgz",
|
||||
"integrity": "sha512-bT0guA1bpxEJ/ZhTRniQf7rNF8ybvXOuWbNIeLABaV5NGjx4EtOWBTSRGWFU9ZWVkPOZ+HNFP8RMcBokBiZ0Kg==",
|
||||
"version": "1.0.0-rc.17",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.0-rc.17.tgz",
|
||||
"integrity": "sha512-IS+W7epTcwANmFSQFrS1SivEXHtl1JtuQA9wlxrZTcNi6mx+FDOYrakGevvvTwgj2JvWiK8B29/qD9BELZPyXQ==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
@@ -2200,9 +2200,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-linux-arm64-gnu": {
|
||||
"version": "1.0.0-rc.16",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.0-rc.16.tgz",
|
||||
"integrity": "sha512-+tHktCHWV8BDQSjemUqm/Jl/TPk3QObCTIjmdDy/nlupcujZghmKK2962LYrqFpWu+ai01AN/REOH3NEpqvYQg==",
|
||||
"version": "1.0.0-rc.17",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.0-rc.17.tgz",
|
||||
"integrity": "sha512-e6usGaHKW5BMNZOymS1UcEYGowQMWcgZ71Z17Sl/h2+ZziNJ1a9n3Zvcz6LdRyIW5572wBCTH/Z+bKuZouGk9Q==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -2216,9 +2216,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-linux-arm64-musl": {
|
||||
"version": "1.0.0-rc.16",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.0-rc.16.tgz",
|
||||
"integrity": "sha512-3fPzdREH806oRLxpTWW1Gt4tQHs0TitZFOECB2xzCFLPKnSOy90gwA7P29cksYilFO6XVRY1kzga0cL2nRjKPg==",
|
||||
"version": "1.0.0-rc.17",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.0-rc.17.tgz",
|
||||
"integrity": "sha512-b/CgbwAJpmrRLp02RPfhbudf5tZnN9nsPWK82znefso832etkem8H7FSZwxrOI9djcdTP7U6YfNhbRnh7djErg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -2232,9 +2232,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-linux-ppc64-gnu": {
|
||||
"version": "1.0.0-rc.16",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.0-rc.16.tgz",
|
||||
"integrity": "sha512-EKwI1tSrLs7YVw+JPJT/G2dJQ1jl9qlTTTEG0V2Ok/RdOenRfBw2PQdLPyjhIu58ocdBfP7vIRN/pvMsPxs/AQ==",
|
||||
"version": "1.0.0-rc.17",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.0-rc.17.tgz",
|
||||
"integrity": "sha512-4EII1iNGRUN5WwGbF/kOh/EIkoDN9HsupgLQoXfY+D1oyJm7/F4t5PYU5n8SWZgG0FEwakyM8pGgwcBYruGTlA==",
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
@@ -2248,9 +2248,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-linux-s390x-gnu": {
|
||||
"version": "1.0.0-rc.16",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.0-rc.16.tgz",
|
||||
"integrity": "sha512-Uknladnb3Sxqu6SEcqBldQyJUpk8NleooZEc0MbRBJ4inEhRYWZX0NJu12vNf2mqAq7gsofAxHrGghiUYjhaLQ==",
|
||||
"version": "1.0.0-rc.17",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.0-rc.17.tgz",
|
||||
"integrity": "sha512-AH8oq3XqQo4IibpVXvPeLDI5pzkpYn0WiZAfT05kFzoJ6tQNzwRdDYQ45M8I/gslbodRZwW8uxLhbSBbkv96rA==",
|
||||
"cpu": [
|
||||
"s390x"
|
||||
],
|
||||
@@ -2264,9 +2264,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-linux-x64-gnu": {
|
||||
"version": "1.0.0-rc.16",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.0-rc.16.tgz",
|
||||
"integrity": "sha512-FIb8+uG49sZBtLTn+zt1AJ20TqVcqWeSIyoVt0or7uAWesgKaHbiBh6OpA/k9v0LTt+PTrb1Lao133kP4uVxkg==",
|
||||
"version": "1.0.0-rc.17",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.0-rc.17.tgz",
|
||||
"integrity": "sha512-cLnjV3xfo7KslbU41Z7z8BH/E1y5mzUYzAqih1d1MDaIGZRCMqTijqLv76/P7fyHuvUcfGsIpqCdddbxLLK9rA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -2280,9 +2280,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-linux-x64-musl": {
|
||||
"version": "1.0.0-rc.16",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.0-rc.16.tgz",
|
||||
"integrity": "sha512-RuERhF9/EgWxZEXYWCOaViUWHIboceK4/ivdtQ3R0T44NjLkIIlGIAVAuCddFxsZ7vnRHtNQUrt2vR2n2slB2w==",
|
||||
"version": "1.0.0-rc.17",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.0-rc.17.tgz",
|
||||
"integrity": "sha512-0phclDw1spsL7dUB37sIARuis2tAgomCJXAHZlpt8PXZ4Ba0dRP1e+66lsRqrfhISeN9bEGNjQs+T/Fbd7oYGw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -2296,9 +2296,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-openharmony-arm64": {
|
||||
"version": "1.0.0-rc.16",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.0-rc.16.tgz",
|
||||
"integrity": "sha512-mXcXnvd9GpazCxeUCCnZ2+YF7nut+ZOEbE4GtaiPtyY6AkhZWbK70y1KK3j+RDhjVq5+U8FySkKRb/+w0EeUwA==",
|
||||
"version": "1.0.0-rc.17",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.0-rc.17.tgz",
|
||||
"integrity": "sha512-0ag/hEgXOwgw4t8QyQvUCxvEg+V0KBcA6YuOx9g0r02MprutRF5dyljgm3EmR02O292UX7UeS6HzWHAl6KgyhA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -2312,17 +2312,17 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-wasm32-wasi": {
|
||||
"version": "1.0.0-rc.16",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.0-rc.16.tgz",
|
||||
"integrity": "sha512-3Q2KQxnC8IJOLqXmUMoYwyIPZU9hzRbnHaoV3Euz+VVnjZKcY8ktnNP8T9R4/GGQtb27C/UYKABxesKWb8lsvQ==",
|
||||
"version": "1.0.0-rc.17",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.0-rc.17.tgz",
|
||||
"integrity": "sha512-LEXei6vo0E5wTGwpkJ4KoT3OZJRnglwldt5ziLzOlc6qqb55z4tWNq2A+PFqCJuvWWdP53CVhG1Z9NtToDPJrA==",
|
||||
"cpu": [
|
||||
"wasm32"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"@emnapi/core": "1.9.2",
|
||||
"@emnapi/runtime": "1.9.2",
|
||||
"@emnapi/core": "1.10.0",
|
||||
"@emnapi/runtime": "1.10.0",
|
||||
"@napi-rs/wasm-runtime": "^1.1.4"
|
||||
},
|
||||
"engines": {
|
||||
@@ -2330,9 +2330,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-win32-arm64-msvc": {
|
||||
"version": "1.0.0-rc.16",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.0-rc.16.tgz",
|
||||
"integrity": "sha512-tj7XRemQcOcFwv7qhpUxMTBbI5mWMlE4c1Omhg5+h8GuLXzyj8HviYgR+bB2DMDgRqUE+jiDleqSCRjx4aYk/Q==",
|
||||
"version": "1.0.0-rc.17",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.0-rc.17.tgz",
|
||||
"integrity": "sha512-gUmyzBl3SPMa6hrqFUth9sVfcLBlYsbMzBx5PlexMroZStgzGqlZ26pYG89rBb45Mnia+oil6YAIFeEWGWhoZA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -2346,9 +2346,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-win32-x64-msvc": {
|
||||
"version": "1.0.0-rc.16",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.0-rc.16.tgz",
|
||||
"integrity": "sha512-PH5DRZT+F4f2PTXRXR8uJxnBq2po/xFtddyabTJVJs/ZYVHqXPEgNIr35IHTEa6bpa0Q8Awg+ymkTaGnKITw4g==",
|
||||
"version": "1.0.0-rc.17",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.0-rc.17.tgz",
|
||||
"integrity": "sha512-3hkiolcUAvPB9FLb3UZdfjVVNWherN1f/skkGWJP/fgSQhYUZpSIRr0/I8ZK9TkF3F7kxvJAk0+IcKvPHk9qQg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -2362,9 +2362,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/pluginutils": {
|
||||
"version": "1.0.0-rc.16",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.16.tgz",
|
||||
"integrity": "sha512-45+YtqxLYKDWQouLKCrpIZhke+nXxhsw+qAHVzHDVwttyBlHNBVs2K25rDXrZzhpTp9w1FlAlvweV1H++fdZoA==",
|
||||
"version": "1.0.0-rc.17",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.17.tgz",
|
||||
"integrity": "sha512-n8iosDOt6Ig1UhJ2AYqoIhHWh/isz0xpicHTzpKBeotdVsTEcxsSA/i3EVM7gQAj0rU27OLAxCjzlj15IWY7bg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@rollup/plugin-inject": {
|
||||
@@ -10649,13 +10649,13 @@
|
||||
"license": "Unlicense"
|
||||
},
|
||||
"node_modules/rolldown": {
|
||||
"version": "1.0.0-rc.16",
|
||||
"resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.0-rc.16.tgz",
|
||||
"integrity": "sha512-rzi5WqKzEZw3SooTt7cgm4eqIoujPIyGcJNGFL7iPEuajQw7vxMHUkXylu4/vhCkJGXsgRmxqMKXUpT6FEgl0g==",
|
||||
"version": "1.0.0-rc.17",
|
||||
"resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.0-rc.17.tgz",
|
||||
"integrity": "sha512-ZrT53oAKrtA4+YtBWPQbtPOxIbVDbxT0orcYERKd63VJTF13zPcgXTvD4843L8pcsI7M6MErt8QtON6lrB9tyA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@oxc-project/types": "=0.126.0",
|
||||
"@rolldown/pluginutils": "1.0.0-rc.16"
|
||||
"@oxc-project/types": "=0.127.0",
|
||||
"@rolldown/pluginutils": "1.0.0-rc.17"
|
||||
},
|
||||
"bin": {
|
||||
"rolldown": "bin/cli.mjs"
|
||||
@@ -10664,21 +10664,21 @@
|
||||
"node": "^20.19.0 || >=22.12.0"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@rolldown/binding-android-arm64": "1.0.0-rc.16",
|
||||
"@rolldown/binding-darwin-arm64": "1.0.0-rc.16",
|
||||
"@rolldown/binding-darwin-x64": "1.0.0-rc.16",
|
||||
"@rolldown/binding-freebsd-x64": "1.0.0-rc.16",
|
||||
"@rolldown/binding-linux-arm-gnueabihf": "1.0.0-rc.16",
|
||||
"@rolldown/binding-linux-arm64-gnu": "1.0.0-rc.16",
|
||||
"@rolldown/binding-linux-arm64-musl": "1.0.0-rc.16",
|
||||
"@rolldown/binding-linux-ppc64-gnu": "1.0.0-rc.16",
|
||||
"@rolldown/binding-linux-s390x-gnu": "1.0.0-rc.16",
|
||||
"@rolldown/binding-linux-x64-gnu": "1.0.0-rc.16",
|
||||
"@rolldown/binding-linux-x64-musl": "1.0.0-rc.16",
|
||||
"@rolldown/binding-openharmony-arm64": "1.0.0-rc.16",
|
||||
"@rolldown/binding-wasm32-wasi": "1.0.0-rc.16",
|
||||
"@rolldown/binding-win32-arm64-msvc": "1.0.0-rc.16",
|
||||
"@rolldown/binding-win32-x64-msvc": "1.0.0-rc.16"
|
||||
"@rolldown/binding-android-arm64": "1.0.0-rc.17",
|
||||
"@rolldown/binding-darwin-arm64": "1.0.0-rc.17",
|
||||
"@rolldown/binding-darwin-x64": "1.0.0-rc.17",
|
||||
"@rolldown/binding-freebsd-x64": "1.0.0-rc.17",
|
||||
"@rolldown/binding-linux-arm-gnueabihf": "1.0.0-rc.17",
|
||||
"@rolldown/binding-linux-arm64-gnu": "1.0.0-rc.17",
|
||||
"@rolldown/binding-linux-arm64-musl": "1.0.0-rc.17",
|
||||
"@rolldown/binding-linux-ppc64-gnu": "1.0.0-rc.17",
|
||||
"@rolldown/binding-linux-s390x-gnu": "1.0.0-rc.17",
|
||||
"@rolldown/binding-linux-x64-gnu": "1.0.0-rc.17",
|
||||
"@rolldown/binding-linux-x64-musl": "1.0.0-rc.17",
|
||||
"@rolldown/binding-openharmony-arm64": "1.0.0-rc.17",
|
||||
"@rolldown/binding-wasm32-wasi": "1.0.0-rc.17",
|
||||
"@rolldown/binding-win32-arm64-msvc": "1.0.0-rc.17",
|
||||
"@rolldown/binding-win32-x64-msvc": "1.0.0-rc.17"
|
||||
}
|
||||
},
|
||||
"node_modules/rollup": {
|
||||
@@ -12113,15 +12113,15 @@
|
||||
}
|
||||
},
|
||||
"node_modules/vite": {
|
||||
"version": "8.0.9",
|
||||
"resolved": "https://registry.npmjs.org/vite/-/vite-8.0.9.tgz",
|
||||
"integrity": "sha512-t7g7GVRpMXjNpa67HaVWI/8BWtdVIQPCL2WoozXXA7LBGEFK4AkkKkHx2hAQf5x1GZSlcmEDPkVLSGahxnEEZw==",
|
||||
"version": "8.0.10",
|
||||
"resolved": "https://registry.npmjs.org/vite/-/vite-8.0.10.tgz",
|
||||
"integrity": "sha512-rZuUu9j6J5uotLDs+cAA4O5H4K1SfPliUlQwqa6YEwSrWDZzP4rhm00oJR5snMewjxF5V/K3D4kctsUTsIU9Mw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"lightningcss": "^1.32.0",
|
||||
"picomatch": "^4.0.4",
|
||||
"postcss": "^8.5.10",
|
||||
"rolldown": "1.0.0-rc.16",
|
||||
"rolldown": "1.0.0-rc.17",
|
||||
"tinyglobby": "^0.2.16"
|
||||
},
|
||||
"bin": {
|
||||
|
||||
+2
-2
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "bento-pdf",
|
||||
"private": true,
|
||||
"version": "2.8.5",
|
||||
"version": "2.8.6",
|
||||
"license": "AGPL-3.0-only",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
@@ -62,7 +62,7 @@
|
||||
"prettier": "^3.8.1",
|
||||
"typescript": "~5.9.3",
|
||||
"typescript-eslint": "^8.56.1",
|
||||
"vite": "^8.0.9",
|
||||
"vite": "^8.0.10",
|
||||
"vite-plugin-compression": "^0.5.1",
|
||||
"vite-plugin-handlebars": "^2.0.0",
|
||||
"vite-plugin-node-polyfills": "^0.26.0",
|
||||
|
||||
@@ -350,9 +350,12 @@
|
||||
"download": "تنزيل:",
|
||||
"downloadSelected": "تنزيل المحدد",
|
||||
"exportPdf": "تصدير PDF",
|
||||
"uploadPdfFiles": "اختر ملفات PDF",
|
||||
"dragAndDrop": "اسحب وأفلت ملفات PDF هنا، أو انقر للاختيار",
|
||||
"uploadPdfFiles": "اختر ملفات PDF أو صور",
|
||||
"dragAndDrop": "اسحب وأفلت ملفات PDF أو الصور هنا، أو انقر للاختيار",
|
||||
"selectFiles": "اختر الملفات",
|
||||
"dropToAdd": "أفلت ملفات PDF أو الصور للإضافة",
|
||||
"convertingImages": "جارٍ تحويل الصور إلى PDF...",
|
||||
"failedToConvertImages": "فشل تحويل الصور إلى PDF.",
|
||||
"renderingPages": "جارٍ عرض الصفحات...",
|
||||
"actions": {
|
||||
"duplicatePage": "تكرار هذه الصفحة",
|
||||
|
||||
@@ -350,9 +350,12 @@
|
||||
"download": "Спампаваць:",
|
||||
"downloadSelected": "Спампаваць выбранае",
|
||||
"exportPdf": "Экспартаваць PDF",
|
||||
"uploadPdfFiles": "Выбраць PDF файлы",
|
||||
"dragAndDrop": "Перацягніце PDF файлы сюды або націсніце, каб выбраць",
|
||||
"uploadPdfFiles": "Выбраць PDF або выявы",
|
||||
"dragAndDrop": "Перацягніце PDF або выявы сюды або націсніце, каб выбраць",
|
||||
"selectFiles": "Выбраць файлы",
|
||||
"dropToAdd": "Адпусціце PDF або выявы для дабаўлення",
|
||||
"convertingImages": "Канвертацыя выяў у PDF...",
|
||||
"failedToConvertImages": "Не ўдалося канвертаваць выявы ў PDF.",
|
||||
"renderingPages": "Апрацоўка старонак...",
|
||||
"actions": {
|
||||
"duplicatePage": "Дубляваць гэту старонку",
|
||||
|
||||
@@ -350,9 +350,12 @@
|
||||
"download": "Download:",
|
||||
"downloadSelected": "Download valgte",
|
||||
"exportPdf": "Eksportér PDF",
|
||||
"uploadPdfFiles": "Vælg PDF-filer",
|
||||
"dragAndDrop": "Træk og slip PDF-filer her, eller klik for at vælge",
|
||||
"uploadPdfFiles": "Vælg PDF- eller billedfiler",
|
||||
"dragAndDrop": "Træk og slip PDF- eller billedfiler her, eller klik for at vælge",
|
||||
"selectFiles": "Vælg filer",
|
||||
"dropToAdd": "Slip PDF- eller billedfiler for at tilføje",
|
||||
"convertingImages": "Konverterer billeder til PDF...",
|
||||
"failedToConvertImages": "Kunne ikke konvertere billeder til PDF.",
|
||||
"renderingPages": "Renderer sider...",
|
||||
"actions": {
|
||||
"duplicatePage": "Duplikér denne side",
|
||||
|
||||
@@ -350,9 +350,12 @@
|
||||
"download": "Download:",
|
||||
"downloadSelected": "Auswahl herunterladen",
|
||||
"exportPdf": "PDF exportieren",
|
||||
"uploadPdfFiles": "PDF-Dateien auswählen",
|
||||
"dragAndDrop": "PDF-Dateien hierhin ziehen oder klicken zum Auswählen",
|
||||
"uploadPdfFiles": "PDF- oder Bilddateien auswählen",
|
||||
"dragAndDrop": "PDF- oder Bilddateien hierhin ziehen oder klicken zum Auswählen",
|
||||
"selectFiles": "Dateien auswählen",
|
||||
"dropToAdd": "PDF- oder Bilddateien zum Hinzufügen ablegen",
|
||||
"convertingImages": "Bilder werden in PDF konvertiert...",
|
||||
"failedToConvertImages": "Bilder konnten nicht in PDF konvertiert werden.",
|
||||
"renderingPages": "Seiten werden gerendert...",
|
||||
"actions": {
|
||||
"duplicatePage": "Diese Seite duplizieren",
|
||||
|
||||
@@ -350,9 +350,12 @@
|
||||
"download": "Download:",
|
||||
"downloadSelected": "Download Selected",
|
||||
"exportPdf": "Export PDF",
|
||||
"uploadPdfFiles": "Select PDF Files",
|
||||
"dragAndDrop": "Drag and drop PDF files here, or click to select",
|
||||
"uploadPdfFiles": "Select PDF or image files",
|
||||
"dragAndDrop": "Drag and drop PDF or image files here, or click to select",
|
||||
"selectFiles": "Select Files",
|
||||
"dropToAdd": "Drop PDF or image files to add",
|
||||
"convertingImages": "Converting images to PDF...",
|
||||
"failedToConvertImages": "Failed to convert images to PDF.",
|
||||
"renderingPages": "Rendering pages...",
|
||||
"actions": {
|
||||
"duplicatePage": "Duplicate this page",
|
||||
|
||||
@@ -350,9 +350,12 @@
|
||||
"download": "Descargar:",
|
||||
"downloadSelected": "Descargar Seleccionados",
|
||||
"exportPdf": "Exportar PDF",
|
||||
"uploadPdfFiles": "Seleccionar Archivos PDF",
|
||||
"dragAndDrop": "Arrastra y suelta archivos PDF aquí, o haz clic para seleccionar",
|
||||
"uploadPdfFiles": "Seleccionar archivos PDF o imágenes",
|
||||
"dragAndDrop": "Arrastra y suelta archivos PDF o imágenes aquí, o haz clic para seleccionar",
|
||||
"selectFiles": "Seleccionar Archivos",
|
||||
"dropToAdd": "Suelta archivos PDF o imágenes para añadir",
|
||||
"convertingImages": "Convirtiendo imágenes a PDF...",
|
||||
"failedToConvertImages": "No se pudieron convertir las imágenes a PDF.",
|
||||
"renderingPages": "Renderizando páginas...",
|
||||
"actions": {
|
||||
"duplicatePage": "Duplicar esta página",
|
||||
|
||||
@@ -350,9 +350,12 @@
|
||||
"download": "Téléchargement :",
|
||||
"downloadSelected": "Télécharger la sélection",
|
||||
"exportPdf": "Exporter en PDF",
|
||||
"uploadPdfFiles": "Sélectionner des fichiers PDF",
|
||||
"dragAndDrop": "Glissez-déposez vos fichiers PDF ici ou cliquez pour sélectionner",
|
||||
"uploadPdfFiles": "Sélectionner des fichiers PDF ou des images",
|
||||
"dragAndDrop": "Glissez-déposez vos fichiers PDF ou images ici, ou cliquez pour sélectionner",
|
||||
"selectFiles": "Sélectionner des fichiers",
|
||||
"dropToAdd": "Déposez des fichiers PDF ou images à ajouter",
|
||||
"convertingImages": "Conversion des images en PDF...",
|
||||
"failedToConvertImages": "Échec de la conversion des images en PDF.",
|
||||
"renderingPages": "Rendu des pages...",
|
||||
"actions": {
|
||||
"duplicatePage": "Dupliquer cette page",
|
||||
|
||||
@@ -350,9 +350,12 @@
|
||||
"download": "Unduh:",
|
||||
"downloadSelected": "Unduh yang Dipilih",
|
||||
"exportPdf": "Ekspor PDF",
|
||||
"uploadPdfFiles": "Pilih File PDF",
|
||||
"dragAndDrop": "Seret dan jatuhkan file PDF di sini, atau klik untuk memilih",
|
||||
"uploadPdfFiles": "Pilih File PDF atau Gambar",
|
||||
"dragAndDrop": "Seret dan jatuhkan file PDF atau gambar di sini, atau klik untuk memilih",
|
||||
"selectFiles": "Pilih File",
|
||||
"dropToAdd": "Jatuhkan file PDF atau gambar untuk menambahkan",
|
||||
"convertingImages": "Mengonversi gambar ke PDF...",
|
||||
"failedToConvertImages": "Gagal mengonversi gambar ke PDF.",
|
||||
"renderingPages": "Merender halaman...",
|
||||
"actions": {
|
||||
"duplicatePage": "Duplikat halaman ini",
|
||||
|
||||
@@ -350,9 +350,12 @@
|
||||
"download": "Scarica:",
|
||||
"downloadSelected": "Scarica selezionati",
|
||||
"exportPdf": "Esporta PDF",
|
||||
"uploadPdfFiles": "Seleziona file PDF",
|
||||
"dragAndDrop": "Trascina e rilascia i file PDF qui, oppure clicca per selezionare",
|
||||
"uploadPdfFiles": "Seleziona file PDF o immagini",
|
||||
"dragAndDrop": "Trascina e rilascia file PDF o immagini qui, oppure clicca per selezionare",
|
||||
"selectFiles": "Seleziona file",
|
||||
"dropToAdd": "Rilascia file PDF o immagini per aggiungere",
|
||||
"convertingImages": "Conversione delle immagini in PDF...",
|
||||
"failedToConvertImages": "Impossibile convertire le immagini in PDF.",
|
||||
"renderingPages": "Generazione anteprime pagine...",
|
||||
"actions": {
|
||||
"duplicatePage": "Duplica questa pagina",
|
||||
|
||||
@@ -350,9 +350,12 @@
|
||||
"download": "ダウンロード:",
|
||||
"downloadSelected": "選択したものをダウンロード",
|
||||
"exportPdf": "PDFをエクスポート",
|
||||
"uploadPdfFiles": "PDFファイルを選択",
|
||||
"dragAndDrop": "ここにPDFファイルをドラッグ&ドロップするかクリックして選択します",
|
||||
"uploadPdfFiles": "PDFまたは画像ファイルを選択",
|
||||
"dragAndDrop": "ここにPDFまたは画像ファイルをドラッグ&ドロップするかクリックして選択します",
|
||||
"selectFiles": "ファイルの選択",
|
||||
"dropToAdd": "PDFまたは画像ファイルをドロップして追加",
|
||||
"convertingImages": "画像をPDFに変換中...",
|
||||
"failedToConvertImages": "画像をPDFに変換できませんでした。",
|
||||
"renderingPages": "ページをレンダリング中...",
|
||||
"actions": {
|
||||
"duplicatePage": "このページを複製",
|
||||
|
||||
@@ -350,9 +350,12 @@
|
||||
"download": "다운로드:",
|
||||
"downloadSelected": "선택 항목 다운로드",
|
||||
"exportPdf": "PDF 내보내기",
|
||||
"uploadPdfFiles": "PDF 파일 선택",
|
||||
"dragAndDrop": "PDF 파일을 여기에 끌어다 놓거나 클릭하여 선택하세요",
|
||||
"uploadPdfFiles": "PDF 또는 이미지 파일 선택",
|
||||
"dragAndDrop": "PDF 또는 이미지 파일을 여기에 끌어다 놓거나 클릭하여 선택하세요",
|
||||
"selectFiles": "파일 선택",
|
||||
"dropToAdd": "PDF 또는 이미지 파일을 놓아 추가",
|
||||
"convertingImages": "이미지를 PDF로 변환 중...",
|
||||
"failedToConvertImages": "이미지를 PDF로 변환하지 못했습니다.",
|
||||
"renderingPages": "페이지 렌더링 중...",
|
||||
"actions": {
|
||||
"duplicatePage": "이 페이지 복제",
|
||||
|
||||
@@ -350,9 +350,12 @@
|
||||
"download": "Laden:",
|
||||
"downloadSelected": "Selectie laden",
|
||||
"exportPdf": "PDF exporteren",
|
||||
"uploadPdfFiles": "PDF-bestanden selecteren",
|
||||
"dragAndDrop": "Klik om een bestand te selecteren, of sleep er een hierheen",
|
||||
"uploadPdfFiles": "PDF- of afbeeldingsbestanden selecteren",
|
||||
"dragAndDrop": "Klik om een bestand te selecteren, of sleep een PDF of afbeelding hierheen",
|
||||
"selectFiles": "Bestanden selecteren",
|
||||
"dropToAdd": "Sleep PDF- of afbeeldingsbestanden om toe te voegen",
|
||||
"convertingImages": "Afbeeldingen omzetten naar PDF...",
|
||||
"failedToConvertImages": "Afbeeldingen omzetten naar PDF mislukt.",
|
||||
"renderingPages": "Pagina's verwerken…",
|
||||
"actions": {
|
||||
"duplicatePage": "Deze pagina dupliceren",
|
||||
|
||||
@@ -350,9 +350,12 @@
|
||||
"download": "Baixar:",
|
||||
"downloadSelected": "Baixar Selecionadas",
|
||||
"exportPdf": "Exportar PDF",
|
||||
"uploadPdfFiles": "Selecionar Arquivos PDF",
|
||||
"dragAndDrop": "Arraste arquivos PDF aqui ou clique para selecionar",
|
||||
"uploadPdfFiles": "Selecionar Arquivos PDF ou Imagens",
|
||||
"dragAndDrop": "Arraste arquivos PDF ou imagens aqui ou clique para selecionar",
|
||||
"selectFiles": "Selecionar Arquivos",
|
||||
"dropToAdd": "Solte arquivos PDF ou imagens para adicionar",
|
||||
"convertingImages": "Convertendo imagens em PDF...",
|
||||
"failedToConvertImages": "Falha ao converter imagens em PDF.",
|
||||
"renderingPages": "Renderizando páginas...",
|
||||
"actions": {
|
||||
"duplicatePage": "Duplicar esta página",
|
||||
|
||||
@@ -350,9 +350,12 @@
|
||||
"download": "Скачать:",
|
||||
"downloadSelected": "Скачать выбранное",
|
||||
"exportPdf": "Экспортировать PDF",
|
||||
"uploadPdfFiles": "Выбрать PDF-файлы",
|
||||
"dragAndDrop": "Перетащите PDF-файлы сюда или нажмите, чтобы выбрать",
|
||||
"uploadPdfFiles": "Выбрать PDF-файлы или изображения",
|
||||
"dragAndDrop": "Перетащите PDF-файлы или изображения сюда или нажмите, чтобы выбрать",
|
||||
"selectFiles": "Выбрать файлы",
|
||||
"dropToAdd": "Отпустите PDF-файлы или изображения, чтобы добавить",
|
||||
"convertingImages": "Преобразование изображений в PDF...",
|
||||
"failedToConvertImages": "Не удалось преобразовать изображения в PDF.",
|
||||
"renderingPages": "Отрисовка страниц...",
|
||||
"actions": {
|
||||
"duplicatePage": "Дублировать эту страницу",
|
||||
|
||||
@@ -0,0 +1,387 @@
|
||||
{
|
||||
"nav": {
|
||||
"home": "Domov",
|
||||
"about": "O nás",
|
||||
"contact": "Kontakt",
|
||||
"licensing": "Licencie",
|
||||
"allTools": "Všetky nástroje",
|
||||
"docs": "Dokumentácia",
|
||||
"openMainMenu": "Otvoriť hlavné menu",
|
||||
"language": "Jazyk",
|
||||
"searchLanguage": "Hľadať jazyk…",
|
||||
"noLanguagesFound": "Nenašli sa žiadne jazyky"
|
||||
},
|
||||
"donation": {
|
||||
"message": "Páči sa vám BentoPDF? Pomôžte nám udržať ho bezplatný a open source!",
|
||||
"button": "Prispieť"
|
||||
},
|
||||
"hero": {
|
||||
"title": "Sada",
|
||||
"pdfToolkit": "PDF nástrojov",
|
||||
"builtForPrivacy": "vytvorená pre súkromie",
|
||||
"noSignups": "Žiadne registrácie",
|
||||
"unlimitedUse": "Neobmedzené používanie",
|
||||
"worksOffline": "Funguje offline",
|
||||
"startUsing": "Začať používať teraz"
|
||||
},
|
||||
"usedBy": {
|
||||
"title": "Používajú spoločnosti a ľudia, ktorí pracujú v"
|
||||
},
|
||||
"features": {
|
||||
"title": "Prečo si vybrať",
|
||||
"bentoPdf": "BentoPDF?",
|
||||
"noSignup": {
|
||||
"title": "Žiadna registrácia",
|
||||
"description": "Začnite okamžite, nepotrebujete žiadne účty ani e-maily."
|
||||
},
|
||||
"noUploads": {
|
||||
"title": "Žiadne nahrávanie na server",
|
||||
"description": "100% lokálne spracovanie, vaše súbory nikdy neopustia vaše zariadenie."
|
||||
},
|
||||
"foreverFree": {
|
||||
"title": "Navždy zadarmo",
|
||||
"description": "Všetky nástroje, žiadne skúšobné verzie, žiadne skryté poplatky."
|
||||
},
|
||||
"noLimits": {
|
||||
"title": "Bez obmedzení",
|
||||
"description": "Používajte koľko chcete, bez skrytých limitov."
|
||||
},
|
||||
"batchProcessing": {
|
||||
"title": "Dávkové spracovanie",
|
||||
"description": "Spracujte neobmedzené množstvo PDF dokumentov naraz."
|
||||
},
|
||||
"lightningFast": {
|
||||
"title": "Bleskovo rýchle",
|
||||
"description": "Spracujte PDF okamžite, bez zbytočného čakania."
|
||||
}
|
||||
},
|
||||
"tools": {
|
||||
"title": "Začnite s",
|
||||
"toolsLabel": "Nástroje",
|
||||
"subtitle": "Kliknutím na nástroj otvoríte nahrávanie súborov",
|
||||
"searchPlaceholder": "Vyhľadajte nástroj (napr. 'rozdeliť', 'usporiadať'...)",
|
||||
"backToTools": "Späť na nástroje",
|
||||
"firstLoadNotice": "Prvé načítanie chvíľu trvá, kým sa stiahne náš konverzný nástroj. Potom už budú všetky procesy okamžité."
|
||||
},
|
||||
"upload": {
|
||||
"clickToSelect": "Kliknite pre výber súboru",
|
||||
"orDragAndDrop": "alebo ho potiahnite a pusťte",
|
||||
"pdfOrImages": "PDF alebo obrázky",
|
||||
"filesNeverLeave": "Vaše súbory nikdy neopustia vaše zariadenie.",
|
||||
"addMore": "Pridať ďalšie súbory",
|
||||
"clearAll": "Vymazať všetko",
|
||||
"clearFiles": "Vymazať súbory",
|
||||
"hints": {
|
||||
"singlePdf": "Jeden PDF súbor",
|
||||
"pdfFile": "PDF súbor",
|
||||
"multiplePdfs2": "Viacero PDF súborov (aspoň 2)",
|
||||
"bmpImages": "Obrázky BMP",
|
||||
"oneOrMorePdfs": "Jeden alebo viac PDF súborov",
|
||||
"pdfDocuments": "PDF dokumenty",
|
||||
"oneOrMoreCsv": "Jeden alebo viac CSV súborov",
|
||||
"multiplePdfsSupported": "Podporuje sa viacero PDF súborov",
|
||||
"singleOrMultiplePdfs": "Podporovaný jeden alebo viacero PDF súborov",
|
||||
"singlePdfFile": "Jeden PDF súbor",
|
||||
"pdfWithForms": "PDF súbor s poľami formulára",
|
||||
"heicImages": "Obrázky HEIC/HEIF",
|
||||
"jpgImages": "Obrázky JPG, JPEG, JP2, JPX",
|
||||
"pdfsOrImages": "PDF alebo obrázky",
|
||||
"oneOrMoreOdt": "Jeden alebo viac ODT súborov",
|
||||
"singlePdfOnly": "Iba jeden PDF súbor",
|
||||
"pdfFiles": "PDF súbory",
|
||||
"multiplePdfs": "Viacero PDF súborov",
|
||||
"pngImages": "Obrázky PNG",
|
||||
"pdfFilesOneOrMore": "PDF súbory (jeden alebo viac)",
|
||||
"oneOrMoreRtf": "Jeden alebo viac RTF súborov",
|
||||
"svgGraphics": "Grafika SVG",
|
||||
"tiffImages": "Obrázky TIFF",
|
||||
"webpImages": "Obrázky WebP"
|
||||
}
|
||||
},
|
||||
"howItWorks": {
|
||||
"title": "Ako to funguje",
|
||||
"step1Title": "Nahrať súbor",
|
||||
"step1": "Kliknite alebo presuňte súbor pre začiatok",
|
||||
"step2Title": "Spracovať",
|
||||
"step2": "Kliknutím na tlačidlo spracovať začnete",
|
||||
"step3Title": "Stiahnuť",
|
||||
"step3": "Okamžite si uložte svoj spracovaný súbor"
|
||||
},
|
||||
"relatedTools": {
|
||||
"title": "Súvisiace PDF nástroje"
|
||||
},
|
||||
"loader": {
|
||||
"processing": "Spracováva sa..."
|
||||
},
|
||||
"alert": {
|
||||
"title": "Upozornenie",
|
||||
"ok": "OK"
|
||||
},
|
||||
"preview": {
|
||||
"title": "Náhľad dokumentu",
|
||||
"downloadAsPdf": "Stiahnuť ako PDF",
|
||||
"close": "Zavrieť"
|
||||
},
|
||||
"settings": {
|
||||
"title": "Nastavenia",
|
||||
"shortcuts": "Klávesové skratky",
|
||||
"preferences": "Predvoľby",
|
||||
"displayPreferences": "Predvoľby zobrazenia",
|
||||
"searchShortcuts": "Hľadať skratky...",
|
||||
"shortcutsInfo": "Stlačením a podržaním klávesov nastavíte skratku. Zmeny sa automaticky ukladajú.",
|
||||
"shortcutsWarning": "⚠️ Vyhnite sa bežným skratkám prehliadača (Cmd/Ctrl+W, Cmd/Ctrl+T, Cmd/Ctrl+N atď.), pretože nemusia fungovať spoľahlivo.",
|
||||
"import": "Importovať",
|
||||
"export": "Exportovať",
|
||||
"resetToDefaults": "Obnoviť predvolené",
|
||||
"fullWidthMode": "Režim celej šírky",
|
||||
"fullWidthDescription": "Použiť celú šírku obrazovky pre všetky nástroje namiesto vycentrovaného rozloženia",
|
||||
"compactMode": "Kompaktný režim",
|
||||
"compactModeDescription": "Zobraziť nástroje ako kompaktný zoznam namiesto kariet",
|
||||
"advancedSettings": "Pokročilé nastavenia",
|
||||
"advancedSettingsDescription": "Konfigurácia externých modulov pre spracovanie (PyMuPDF, Ghostscript, CoherentPDF)",
|
||||
"settingsAutoSaved": "Nastavenia sa automaticky ukladajú",
|
||||
"clickToSet": "Kliknutím nastavte",
|
||||
"pressKeys": "Stlačte klávesy...",
|
||||
"warnings": {
|
||||
"alreadyInUse": "Skratka sa už používa",
|
||||
"assignedTo": "je už priradená k:",
|
||||
"chooseDifferent": "Zvoľte si prosím inú skratku.",
|
||||
"reserved": "Upozornenie na vyhradenú skratku",
|
||||
"commonlyUsed": "sa bežne používa pre:",
|
||||
"unreliable": "Táto skratka nemusí fungovať spoľahlivo alebo môže spôsobovať konflikt s prehliadačom/systémom.",
|
||||
"useAnyway": "Chcete ju napriek tomu použiť?",
|
||||
"resetTitle": "Obnoviť skratky",
|
||||
"resetMessage": "Naozaj chcete obnoviť všetky skratky na predvolené?<br><br>Túto akciu nie je možné vrátiť späť.",
|
||||
"importSuccessTitle": "Import úspešný",
|
||||
"importSuccessMessage": "Skratky boli úspešne importované!",
|
||||
"importFailTitle": "Import zlyhal",
|
||||
"importFailMessage": "Nepodarilo sa importovať skratky. Neplatný formát súboru."
|
||||
}
|
||||
},
|
||||
"warning": {
|
||||
"title": "Varovanie",
|
||||
"cancel": "Zrušiť",
|
||||
"proceed": "Pokračovať"
|
||||
},
|
||||
"compliance": {
|
||||
"title": "Vaše dáta nikdy neopustia vaše zariadenie",
|
||||
"weKeep": "Udržujeme",
|
||||
"yourInfoSafe": "vaše informácie v bezpečí",
|
||||
"byFollowingStandards": "dodržiavaním globálnych bezpečnostných štandardov.",
|
||||
"processingLocal": "Všetko spracovanie prebieha lokálne priamo na vašom zariadení.",
|
||||
"gdpr": {
|
||||
"title": "Súlad s GDPR",
|
||||
"description": "Chráni osobné údaje a súkromie jednotlivcov v rámci Európskej únie."
|
||||
},
|
||||
"ccpa": {
|
||||
"title": "Súlad s CCPA",
|
||||
"description": "Dáva obyvateľom Kalifornie práva týkajúce sa toho, ako sa zhromažďujú, používajú a zdieľajú ich osobné informácie."
|
||||
},
|
||||
"hipaa": {
|
||||
"title": "Súlad s HIPAA",
|
||||
"description": "Stanovuje ochranné opatrenia pre nakladanie s citlivými zdravotnými informáciami v systéme zdravotnej starostlivosti v USA."
|
||||
}
|
||||
},
|
||||
"faq": {
|
||||
"title": "Často kladené",
|
||||
"questions": "otázky",
|
||||
"sectionTitle": "Často kladené otázky",
|
||||
"isFree": {
|
||||
"question": "Je BentoPDF naozaj zadarmo?",
|
||||
"answer": "Áno, absolútne. Všetky nástroje na BentoPDF sú na 100% bezplatné, bez obmedzení súborov, bez registrácie a bez vodotlače. Veríme, že každý si zaslúži prístup k jednoduchým a výkonným nástrojom PDF bez poplatkov."
|
||||
},
|
||||
"areFilesSecure": {
|
||||
"question": "Sú moje súbory v bezpečí? Kde sa spracúvajú?",
|
||||
"answer": "Vaše súbory sú v maximálnom bezpečí, pretože nikdy neopustia váš počítač. Všetko spracovanie prebieha priamo vo vašom webovom prehliadači (na strane klienta). Nikdy nenahrávame vaše súbory na server, takže máte úplné súkromie a kontrolu nad svojimi dokumentmi."
|
||||
},
|
||||
"platforms": {
|
||||
"question": "Funguje to na Macu, vo Windows a na mobilných zariadeniach?",
|
||||
"answer": "Áno! Keďže BentoPDF beží výlučne vo vašom prehliadači, funguje na akomkoľvek operačnom systéme s moderným webovým prehliadačom vrátane Windows, macOS, Linux, iOS a Android."
|
||||
},
|
||||
"gdprCompliant": {
|
||||
"question": "Je BentoPDF v súlade s GDPR?",
|
||||
"answer": "Áno. BentoPDF je plne v súlade s GDPR. Keďže všetko spracovanie súborov prebieha lokálne vo vašom prehliadači a my vaše súbory nikdy nezhromažďujeme ani neprenášame na žiadny server, nemáme prístup k vašim údajom. To zaisťuje, že máte svoje dokumenty vždy pod kontrolou."
|
||||
},
|
||||
"dataStorage": {
|
||||
"question": "Ukladáte alebo sledujete nejaké moje súbory?",
|
||||
"answer": "Nie. Vaše súbory nikdy neukladáme, nesledujeme ani nezaznamenávame. Všetko, čo v BentoPDF urobíte, sa odohráva v pamäti vášho prehliadača a po zatvorení stránky zmizne. Nedochádza k žiadnemu nahrávaniu, logovaniu ani sa nevyužívajú žiadne servery."
|
||||
},
|
||||
"different": {
|
||||
"question": "V čom je BentoPDF odlišné od iných PDF nástrojov?",
|
||||
"answer": "Väčšina PDF nástrojov nahráva vaše súbory na spracovanie na server. BentoPDF to nerobí. Na spracovanie vašich súborov priamo vo vašom prehliadači používame bezpečné moderné webové technológie. Znamená to vyšší výkon, silnejšiu ochranu súkromia a úplný pokoj v duši."
|
||||
},
|
||||
"browserBased": {
|
||||
"question": "Ako ma spracovanie založené na prehliadači chráni?",
|
||||
"answer": "Tým, že BentoPDF beží výlučne vnútri vášho prehliadača, zabezpečuje, že vaše súbory nikdy neopustia vaše zariadenie. Tým sa eliminujú riziká napadnutia serverov, úniku dát alebo neoprávneného prístupu. Vaše súbory zostávajú vždy vaše."
|
||||
},
|
||||
"analytics": {
|
||||
"question": "Používate súbory cookie alebo analytiku na moje sledovanie?",
|
||||
"answer": "Záleží nám na vašom súkromí. BentoPDF nesleduje osobné údaje. Nástroj Simple Analytics využívame výhradne na zobrazenie anonymného počtu návštev. Znamená to, že môžeme vedieť, koľko používateľov navštívilo našu stránku, ale nikdy nevieme, kto ste. Simple Analytics je plne v súlade s GDPR a rešpektuje vaše súkromie."
|
||||
}
|
||||
},
|
||||
"testimonials": {
|
||||
"title": "Čo hovoria",
|
||||
"users": "naši",
|
||||
"say": "používatelia"
|
||||
},
|
||||
"support": {
|
||||
"title": "Páči sa vám moja práca?",
|
||||
"description": "BentoPDF je srdcový projekt, vytvorený s cieľom poskytnúť bezplatnú, súkromnú a výkonnú sadu PDF nástrojov pre každého. Ak sa vám zdá užitočný, zvážte podporu jeho vývoja. Každá káva pomôže!",
|
||||
"buyMeCoffee": "Kúpte mi kávu"
|
||||
},
|
||||
"footer": {
|
||||
"copyright": "© 2026 BentoPDF. Všetky práva vyhradené.",
|
||||
"version": "Verzia",
|
||||
"company": "Spoločnosť",
|
||||
"aboutUs": "O nás",
|
||||
"faqLink": "Časté otázky",
|
||||
"contactUs": "Kontaktujte nás",
|
||||
"legal": "Právne informácie",
|
||||
"termsAndConditions": "Obchodné podmienky",
|
||||
"privacyPolicy": "Zásady ochrany osobných údajov",
|
||||
"followUs": "Sledujte nás"
|
||||
},
|
||||
"merge": {
|
||||
"title": "Zlúčiť PDF",
|
||||
"description": "Spojte celé súbory alebo vyberte konkrétne strany na zlúčenie do nového dokumentu.",
|
||||
"fileMode": "Režim súborov",
|
||||
"pageMode": "Režim strán",
|
||||
"howItWorks": "Ako to funguje:",
|
||||
"fileModeInstructions": [
|
||||
"Kliknite a potiahnite ikonu pre zmenu poradia súborov.",
|
||||
"V poli \"Strany\" pri každom súbore môžete zadať rozsahy (napr. \"1-3, 5\") na zlúčenie iba týchto strán.",
|
||||
"Ak chcete zahrnúť všetky strany z daného súboru, nechajte pole \"Strany\" prázdne."
|
||||
],
|
||||
"pageModeInstructions": [
|
||||
"Všetky strany z vašich nahratých PDF dokumentov sú zobrazené nižšie.",
|
||||
"Jednoducho potiahnite a pusťte jednotlivé miniatúry strán a vytvorte presné poradie pre váš nový súbor."
|
||||
],
|
||||
"mergePdfs": "Zlúčiť PDF"
|
||||
},
|
||||
"common": {
|
||||
"page": "Strana",
|
||||
"pages": "Strany",
|
||||
"of": "z",
|
||||
"download": "Stiahnuť",
|
||||
"cancel": "Zrušiť",
|
||||
"save": "Uložiť",
|
||||
"delete": "Zmazať",
|
||||
"edit": "Upraviť",
|
||||
"add": "Pridať",
|
||||
"remove": "Odstrániť",
|
||||
"loading": "Načítava sa...",
|
||||
"loadingPageCount": "Načítavajú sa strany...",
|
||||
"error": "Chyba",
|
||||
"success": "Úspech",
|
||||
"file": "Súbor",
|
||||
"files": "Súbory",
|
||||
"close": "Zavrieť",
|
||||
"convert": "Konvertovať",
|
||||
"unknownError": "Neznáma chyba"
|
||||
},
|
||||
"about": {
|
||||
"hero": {
|
||||
"title": "Veríme, že PDF nástroje by mali byť",
|
||||
"subtitle": "rýchle, súkromné a bezplatné.",
|
||||
"noCompromises": "Bez kompromisov."
|
||||
},
|
||||
"mission": {
|
||||
"title": "Naša misia",
|
||||
"description": "Poskytnúť najkomplexnejšiu sadu PDF nástrojov, ktorá rešpektuje vaše súkromie a nikdy nepožaduje platbu. Veríme, že základné nástroje pre prácu s dokumentmi by mali byť dostupné pre každého, všade a bez bariér."
|
||||
},
|
||||
"philosophy": {
|
||||
"label": "Naša hlavná filozofia",
|
||||
"title": "Súkromie na prvom mieste. Vždy.",
|
||||
"description": "V dobe, kedy sú dáta komoditou, k tomu pristupujeme inak. Všetko spracovanie nástrojov BentoPDF prebieha lokálne vo vašom prehliadači. To znamená, že vaše súbory sa nikdy nedotknú našich serverov, nikdy nevidíme vaše dokumenty a nesledujeme, čo robíte. Vaše dokumenty zostávajú úplne a jednoznačne súkromné. Nie je to len funkcia, je to náš základ."
|
||||
},
|
||||
"whyBentopdf": {
|
||||
"title": "Prečo",
|
||||
"speed": {
|
||||
"title": "Zostavené pre rýchlosť",
|
||||
"description": "Žiadne čakanie na nahrávanie alebo sťahovanie zo servera. Spracovaním súborov priamo vo vašom prehliadači pomocou moderných webových technológií ako WebAssembly, ponúkame bezkonkurenčnú rýchlosť pre všetky naše nástroje."
|
||||
},
|
||||
"free": {
|
||||
"title": "Úplne zadarmo",
|
||||
"description": "Žiadne skúšobné verzie, žiadne predplatné, žiadne skryté poplatky a žiadne „prémiové“ funkcie držané ako rukojemníci. Veríme, že výkonné PDF nástroje by mali byť verejnou službou, nie centrom zisku."
|
||||
},
|
||||
"noAccount": {
|
||||
"title": "Nevyžaduje sa žiadny účet",
|
||||
"description": "Začnite používať akýkoľvek nástroj okamžite. Nepotrebujeme váš e-mail, heslo ani žiadne osobné údaje. Váš pracovný postup by mal byť plynulý a anonymný."
|
||||
},
|
||||
"openSource": {
|
||||
"title": "Duch open-source",
|
||||
"description": "Vytvorené s ohľadom na transparentnosť. Využívame neuveriteľné open-source knižnice ako PDF-lib a PDF.js, a veríme v úsilie poháňané komunitou s cieľom sprístupniť výkonné nástroje všetkým."
|
||||
}
|
||||
},
|
||||
"cta": {
|
||||
"title": "Ste pripravení začať?",
|
||||
"description": "Pridajte sa k tisícom používateľov, ktorí dôverujú BentoPDF vo svojich každodenných potrebách s dokumentmi. Vyskúšajte, aký rozdiel môže urobiť súkromie a výkon.",
|
||||
"button": "Preskúmať všetky nástroje"
|
||||
}
|
||||
},
|
||||
"contact": {
|
||||
"title": "Kontaktujte nás",
|
||||
"subtitle": "Radi o vás budeme počuť. Či už máte otázku, spätnú väzbu, alebo návrh na novú funkciu, neváhajte sa ozvať.",
|
||||
"email": "Môžete nás kontaktovať priamo e-mailom na:"
|
||||
},
|
||||
"licensing": {
|
||||
"title": "Licencovanie pre",
|
||||
"subtitle": "Vyberte si licenciu, ktorá vyhovuje vašim potrebám."
|
||||
},
|
||||
"multiTool": {
|
||||
"uploadPdfs": "Nahrať PDF",
|
||||
"upload": "Nahrať",
|
||||
"addBlankPage": "Pridať prázdnu stranu",
|
||||
"edit": "Upraviť:",
|
||||
"undo": "Späť",
|
||||
"redo": "Znova",
|
||||
"reset": "Obnoviť",
|
||||
"selection": "Výber:",
|
||||
"selectAll": "Vybrať všetko",
|
||||
"deselectAll": "Zrušiť výber",
|
||||
"rotate": "Otočiť:",
|
||||
"rotateLeft": "Doľava",
|
||||
"rotateRight": "Doprava",
|
||||
"transform": "Transformovať:",
|
||||
"duplicate": "Duplikovať",
|
||||
"split": "Rozdeliť",
|
||||
"clear": "Vyčistiť:",
|
||||
"delete": "Zmazať",
|
||||
"download": "Stiahnuť:",
|
||||
"downloadSelected": "Stiahnuť vybrané",
|
||||
"exportPdf": "Exportovať PDF",
|
||||
"uploadPdfFiles": "Vyberte PDF alebo obrázky",
|
||||
"dragAndDrop": "Potiahnite a pusťte PDF alebo obrázky sem, alebo kliknite pre výber",
|
||||
"selectFiles": "Vybrať súbory",
|
||||
"dropToAdd": "Pusťte PDF alebo obrázky pre pridanie",
|
||||
"convertingImages": "Konvertujú sa obrázky do PDF...",
|
||||
"failedToConvertImages": "Nepodarilo sa konvertovať obrázky do PDF.",
|
||||
"renderingPages": "Vykresľujú sa strany...",
|
||||
"actions": {
|
||||
"duplicatePage": "Duplikovať túto stranu",
|
||||
"deletePage": "Zmazať túto stranu",
|
||||
"insertPdf": "Vložiť PDF za túto stranu",
|
||||
"toggleSplit": "Prepnúť rozdelenie za touto stranou"
|
||||
},
|
||||
"pleaseWait": "Prosím čakajte",
|
||||
"pagesRendering": "Strany sa stále vykresľujú. Prosím čakajte...",
|
||||
"noPagesSelected": "Žiadne vybrané strany",
|
||||
"selectOnePage": "Prosím vyberte aspoň jednu stranu na stiahnutie.",
|
||||
"noPages": "Žiadne strany",
|
||||
"noPagesToExport": "Nie sú tu žiadne strany na export.",
|
||||
"renderingTitle": "Vykresľujú sa náhľady strán",
|
||||
"errorRendering": "Nepodarilo sa vykresliť miniatúry strán",
|
||||
"error": "Chyba",
|
||||
"failedToLoad": "Nepodarilo sa načítať"
|
||||
},
|
||||
"simpleMode": {
|
||||
"title": "Nástroje PDF",
|
||||
"subtitle": "Vyberte si nástroj pre začiatok"
|
||||
},
|
||||
"disabledTool": {
|
||||
"title": "Nástroj nedostupný",
|
||||
"heading": "Tento nástroj bol zakázaný",
|
||||
"message": "Tento nástroj nie je vo vašom nasadení dostupný. Pre viac informácií kontaktujte svojho administrátora.",
|
||||
"backHome": "Späť domov"
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -350,9 +350,12 @@
|
||||
"download": "Ladda ner:",
|
||||
"downloadSelected": "Ladda ner markerade",
|
||||
"exportPdf": "Exportera PDF",
|
||||
"uploadPdfFiles": "Välj PDF-filer",
|
||||
"dragAndDrop": "Dra och släpp PDF-filer här, eller klicka för att välja",
|
||||
"uploadPdfFiles": "Välj PDF- eller bildfiler",
|
||||
"dragAndDrop": "Dra och släpp PDF- eller bildfiler här, eller klicka för att välja",
|
||||
"selectFiles": "Välj filer",
|
||||
"dropToAdd": "Släpp PDF- eller bildfiler för att lägga till",
|
||||
"convertingImages": "Konverterar bilder till PDF...",
|
||||
"failedToConvertImages": "Kunde inte konvertera bilder till PDF.",
|
||||
"renderingPages": "Renderar sidor...",
|
||||
"actions": {
|
||||
"duplicatePage": "Duplicera denna sida",
|
||||
|
||||
@@ -350,9 +350,12 @@
|
||||
"download": "İndir:",
|
||||
"downloadSelected": "Seçilenleri İndir",
|
||||
"exportPdf": "PDF Olarak Dışa Aktar",
|
||||
"uploadPdfFiles": "PDF Dosyalarını Seçin",
|
||||
"dragAndDrop": "PDF dosyalarını buraya sürükleyip bırakın veya seçmek için tıklayın",
|
||||
"uploadPdfFiles": "PDF veya Görsel Dosyalarını Seçin",
|
||||
"dragAndDrop": "PDF veya görsel dosyalarını buraya sürükleyip bırakın veya seçmek için tıklayın",
|
||||
"selectFiles": "Dosya Seç",
|
||||
"dropToAdd": "Eklemek için PDF veya görsel dosyalarını bırakın",
|
||||
"convertingImages": "Görseller PDF'e dönüştürülüyor...",
|
||||
"failedToConvertImages": "Görseller PDF'e dönüştürülemedi.",
|
||||
"renderingPages": "Sayfalar oluşturuluyor...",
|
||||
"actions": {
|
||||
"duplicatePage": "Bu sayfayı çoğalt",
|
||||
|
||||
@@ -350,9 +350,12 @@
|
||||
"download": "Завантажити:",
|
||||
"downloadSelected": "Завантажити вибране",
|
||||
"exportPdf": "Експортувати PDF",
|
||||
"uploadPdfFiles": "Виберіть PDF-файли",
|
||||
"dragAndDrop": "Перетягніть PDF або натисніть для вибору",
|
||||
"uploadPdfFiles": "Виберіть PDF-файли або зображення",
|
||||
"dragAndDrop": "Перетягніть PDF або зображення сюди, або натисніть для вибору",
|
||||
"selectFiles": "Вибрати файли",
|
||||
"dropToAdd": "Відпустіть PDF або зображення для додавання",
|
||||
"convertingImages": "Перетворення зображень на PDF...",
|
||||
"failedToConvertImages": "Не вдалося перетворити зображення на PDF.",
|
||||
"renderingPages": "Рендеринг сторінок...",
|
||||
"actions": {
|
||||
"duplicatePage": "Дублювати сторінку",
|
||||
|
||||
@@ -350,9 +350,12 @@
|
||||
"download": "Tải xuống:",
|
||||
"downloadSelected": "Tải xuống đã chọn",
|
||||
"exportPdf": "Xuất PDF",
|
||||
"uploadPdfFiles": "Chọn tệp PDF",
|
||||
"dragAndDrop": "Kéo và thả tệp PDF vào đây, hoặc nhấp để chọn",
|
||||
"uploadPdfFiles": "Chọn tệp PDF hoặc hình ảnh",
|
||||
"dragAndDrop": "Kéo và thả tệp PDF hoặc hình ảnh vào đây, hoặc nhấp để chọn",
|
||||
"selectFiles": "Chọn tệp",
|
||||
"dropToAdd": "Thả tệp PDF hoặc hình ảnh để thêm",
|
||||
"convertingImages": "Đang chuyển đổi hình ảnh sang PDF...",
|
||||
"failedToConvertImages": "Không thể chuyển đổi hình ảnh sang PDF.",
|
||||
"renderingPages": "Đang kết xuất trang...",
|
||||
"actions": {
|
||||
"duplicatePage": "Nhân bản trang này",
|
||||
|
||||
@@ -350,9 +350,12 @@
|
||||
"download": "下載:",
|
||||
"downloadSelected": "下載選取的項目",
|
||||
"exportPdf": "匯出 PDF",
|
||||
"uploadPdfFiles": "選擇 PDF 檔案",
|
||||
"dragAndDrop": "拖放 PDF 檔案至此處,或是點擊以選取",
|
||||
"uploadPdfFiles": "選擇 PDF 或圖片檔案",
|
||||
"dragAndDrop": "拖放 PDF 或圖片檔案至此處,或是點擊以選取",
|
||||
"selectFiles": "選擇檔案",
|
||||
"dropToAdd": "拖放 PDF 或圖片檔案以新增",
|
||||
"convertingImages": "正在將圖片轉換為 PDF...",
|
||||
"failedToConvertImages": "圖片轉換為 PDF 失敗。",
|
||||
"renderingPages": "渲染頁面...",
|
||||
"actions": {
|
||||
"duplicatePage": "複製此頁",
|
||||
|
||||
@@ -350,9 +350,12 @@
|
||||
"download": "下载:",
|
||||
"downloadSelected": "下载选中",
|
||||
"exportPdf": "导出 PDF",
|
||||
"uploadPdfFiles": "选择 PDF 文件",
|
||||
"dragAndDrop": "将 PDF 文件拖放到此处,或点击选择",
|
||||
"uploadPdfFiles": "选择 PDF 或图片文件",
|
||||
"dragAndDrop": "将 PDF 或图片文件拖放到此处,或点击选择",
|
||||
"selectFiles": "选择文件",
|
||||
"dropToAdd": "拖放 PDF 或图片文件以添加",
|
||||
"convertingImages": "正在将图片转换为 PDF...",
|
||||
"failedToConvertImages": "图片转换为 PDF 失败。",
|
||||
"renderingPages": "正在渲染页面...",
|
||||
"actions": {
|
||||
"duplicatePage": "复制此页",
|
||||
|
||||
@@ -27,6 +27,7 @@ const DEFAULT_WASM_ORIGINS = {
|
||||
};
|
||||
const DEFAULT_CORS_PROXY_ORIGIN =
|
||||
'https://bentopdf-cors-proxy.bentopdf.workers.dev';
|
||||
const DEFAULT_OCR_FONT_CDN_ORIGIN = 'https://rawcdn.githack.com';
|
||||
|
||||
const wasmOrigins = [
|
||||
originOf(process.env.VITE_WASM_PYMUPDF_URL) || DEFAULT_WASM_ORIGINS.pymupdf,
|
||||
@@ -43,13 +44,15 @@ const tesseractOrigins = uniq([
|
||||
const corsProxyOrigin =
|
||||
originOf(process.env.VITE_CORS_PROXY_URL) || DEFAULT_CORS_PROXY_ORIGIN;
|
||||
|
||||
const ocrFontOrigin = originOf(process.env.VITE_OCR_FONT_BASE_URL);
|
||||
const ocrFontOrigin =
|
||||
originOf(process.env.VITE_OCR_FONT_BASE_URL) || DEFAULT_OCR_FONT_CDN_ORIGIN;
|
||||
|
||||
const scriptOrigins = uniq([...wasmOrigins, ...tesseractOrigins]);
|
||||
const connectOrigins = uniq([
|
||||
...wasmOrigins,
|
||||
...tesseractOrigins,
|
||||
corsProxyOrigin,
|
||||
ocrFontOrigin,
|
||||
]);
|
||||
const fontOrigins = uniq([ocrFontOrigin].filter(Boolean));
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; img-src 'self' data: blob: https:; font-src 'self' data: https://fonts.gstatic.com; connect-src 'self' https://api.github.com https://fonts.gstatic.com https://cdn.jsdelivr.net https://bentopdf-cors-proxy.bentopdf.workers.dev; object-src 'none'; base-uri 'self'; frame-ancestors 'self'; form-action 'self'" always;
|
||||
add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; img-src 'self' data: blob: https:; font-src 'self' data: https://fonts.gstatic.com https://rawcdn.githack.com; connect-src 'self' https://api.github.com https://fonts.gstatic.com https://cdn.jsdelivr.net https://bentopdf-cors-proxy.bentopdf.workers.dev https://rawcdn.githack.com; object-src 'none'; base-uri 'self'; frame-ancestors 'self'; form-action 'self'" always;
|
||||
add_header X-Frame-Options "SAMEORIGIN" always;
|
||||
add_header X-Content-Type-Options "nosniff" always;
|
||||
add_header X-XSS-Protection "1; mode=block" always;
|
||||
|
||||
@@ -447,6 +447,54 @@
|
||||
"created_at": "2026-04-10T12:20:50Z",
|
||||
"repoId": 1074785178,
|
||||
"pullRequestNo": 650
|
||||
},
|
||||
{
|
||||
"name": "marek-mraz",
|
||||
"id": 193615612,
|
||||
"comment_id": 4613282157,
|
||||
"created_at": "2026-06-03T14:14:11Z",
|
||||
"repoId": 1074785178,
|
||||
"pullRequestNo": 736
|
||||
},
|
||||
{
|
||||
"name": "mondeep31",
|
||||
"id": 97219431,
|
||||
"comment_id": 4621895746,
|
||||
"created_at": "2026-06-04T11:55:51Z",
|
||||
"repoId": 1074785178,
|
||||
"pullRequestNo": 739
|
||||
},
|
||||
{
|
||||
"name": "mdevolde",
|
||||
"id": 73021358,
|
||||
"comment_id": 4623697031,
|
||||
"created_at": "2026-06-04T15:36:43Z",
|
||||
"repoId": 1074785178,
|
||||
"pullRequestNo": 740
|
||||
},
|
||||
{
|
||||
"name": "upmcplanetracker",
|
||||
"id": 219436948,
|
||||
"comment_id": 4660768210,
|
||||
"created_at": "2026-06-09T14:25:39Z",
|
||||
"repoId": 1074785178,
|
||||
"pullRequestNo": 746
|
||||
},
|
||||
{
|
||||
"name": "r0ckarong",
|
||||
"id": 4920542,
|
||||
"comment_id": 4719643792,
|
||||
"created_at": "2026-06-16T14:07:01Z",
|
||||
"repoId": 1074785178,
|
||||
"pullRequestNo": 752
|
||||
},
|
||||
{
|
||||
"name": "nine-dev9",
|
||||
"id": 82376723,
|
||||
"comment_id": 4772117353,
|
||||
"created_at": "2026-06-22T19:17:49Z",
|
||||
"repoId": 1074785178,
|
||||
"pullRequestNo": 761
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -160,59 +160,59 @@ export const languageToFontFamily: Record<string, string> = {
|
||||
|
||||
export const fontFamilyToUrl: Record<string, string> = {
|
||||
'Noto Sans JP':
|
||||
'https://raw.githack.com/googlefonts/noto-cjk/main/Sans/OTF/Japanese/NotoSansCJKjp-Regular.otf',
|
||||
'https://rawcdn.githack.com/googlefonts/noto-cjk/f8d157532fbfaeda587e826d4cd5b21a49186f7c/Sans/OTF/Japanese/NotoSansCJKjp-Regular.otf',
|
||||
'Noto Sans SC':
|
||||
'https://raw.githack.com/googlefonts/noto-cjk/main/Sans/OTF/SimplifiedChinese/NotoSansCJKsc-Regular.otf',
|
||||
'https://rawcdn.githack.com/googlefonts/noto-cjk/f8d157532fbfaeda587e826d4cd5b21a49186f7c/Sans/OTF/SimplifiedChinese/NotoSansCJKsc-Regular.otf',
|
||||
'Noto Sans TC':
|
||||
'https://raw.githack.com/googlefonts/noto-cjk/main/Sans/OTF/TraditionalChinese/NotoSansCJKtc-Regular.otf',
|
||||
'https://rawcdn.githack.com/googlefonts/noto-cjk/f8d157532fbfaeda587e826d4cd5b21a49186f7c/Sans/OTF/TraditionalChinese/NotoSansCJKtc-Regular.otf',
|
||||
'Noto Sans KR':
|
||||
'https://raw.githack.com/googlefonts/noto-cjk/main/Sans/OTF/Korean/NotoSansCJKkr-Regular.otf',
|
||||
'https://rawcdn.githack.com/googlefonts/noto-cjk/f8d157532fbfaeda587e826d4cd5b21a49186f7c/Sans/OTF/Korean/NotoSansCJKkr-Regular.otf',
|
||||
'Noto Naskh Arabic':
|
||||
'https://raw.githack.com/googlefonts/noto-fonts/main/hinted/ttf/NotoNaskhArabic/NotoNaskhArabic-Regular.ttf',
|
||||
'https://rawcdn.githack.com/googlefonts/noto-fonts/ffebf8c1ee449e544955a7e813c54f9b73848eac/hinted/ttf/NotoNaskhArabic/NotoNaskhArabic-Regular.ttf',
|
||||
'Noto Sans Devanagari':
|
||||
'https://raw.githack.com/googlefonts/noto-fonts/main/unhinted/ttf/NotoSansDevanagari/NotoSansDevanagari-Regular.ttf',
|
||||
'https://rawcdn.githack.com/googlefonts/noto-fonts/ffebf8c1ee449e544955a7e813c54f9b73848eac/unhinted/ttf/NotoSansDevanagari/NotoSansDevanagari-Regular.ttf',
|
||||
'Noto Sans Bengali':
|
||||
'https://raw.githack.com/googlefonts/noto-fonts/main/hinted/ttf/NotoSansBengali/NotoSansBengali-Regular.ttf',
|
||||
'https://rawcdn.githack.com/googlefonts/noto-fonts/ffebf8c1ee449e544955a7e813c54f9b73848eac/hinted/ttf/NotoSansBengali/NotoSansBengali-Regular.ttf',
|
||||
'Noto Sans Gujarati':
|
||||
'https://raw.githack.com/googlefonts/noto-fonts/main/hinted/ttf/NotoSansGujarati/NotoSansGujarati-Regular.ttf',
|
||||
'https://rawcdn.githack.com/googlefonts/noto-fonts/ffebf8c1ee449e544955a7e813c54f9b73848eac/hinted/ttf/NotoSansGujarati/NotoSansGujarati-Regular.ttf',
|
||||
'Noto Sans Kannada':
|
||||
'https://raw.githack.com/googlefonts/noto-fonts/main/hinted/ttf/NotoSansKannada/NotoSansKannada-Regular.ttf',
|
||||
'https://rawcdn.githack.com/googlefonts/noto-fonts/ffebf8c1ee449e544955a7e813c54f9b73848eac/hinted/ttf/NotoSansKannada/NotoSansKannada-Regular.ttf',
|
||||
'Noto Sans Malayalam':
|
||||
'https://raw.githack.com/googlefonts/noto-fonts/main/hinted/ttf/NotoSansMalayalam/NotoSansMalayalam-Regular.ttf',
|
||||
'https://rawcdn.githack.com/googlefonts/noto-fonts/ffebf8c1ee449e544955a7e813c54f9b73848eac/hinted/ttf/NotoSansMalayalam/NotoSansMalayalam-Regular.ttf',
|
||||
'Noto Sans Oriya':
|
||||
'https://raw.githack.com/googlefonts/noto-fonts/main/hinted/ttf/NotoSansOriya/NotoSansOriya-Regular.ttf',
|
||||
'https://rawcdn.githack.com/googlefonts/noto-fonts/ffebf8c1ee449e544955a7e813c54f9b73848eac/hinted/ttf/NotoSansOriya/NotoSansOriya-Regular.ttf',
|
||||
'Noto Sans Gurmukhi':
|
||||
'https://raw.githack.com/googlefonts/noto-fonts/main/hinted/ttf/NotoSansGurmukhi/NotoSansGurmukhi-Regular.ttf',
|
||||
'https://rawcdn.githack.com/googlefonts/noto-fonts/ffebf8c1ee449e544955a7e813c54f9b73848eac/hinted/ttf/NotoSansGurmukhi/NotoSansGurmukhi-Regular.ttf',
|
||||
'Noto Sans Tamil':
|
||||
'https://raw.githack.com/googlefonts/noto-fonts/main/hinted/ttf/NotoSansTamil/NotoSansTamil-Regular.ttf',
|
||||
'https://rawcdn.githack.com/googlefonts/noto-fonts/ffebf8c1ee449e544955a7e813c54f9b73848eac/hinted/ttf/NotoSansTamil/NotoSansTamil-Regular.ttf',
|
||||
'Noto Sans Telugu':
|
||||
'https://raw.githack.com/googlefonts/noto-fonts/main/hinted/ttf/NotoSansTelugu/NotoSansTelugu-Regular.ttf',
|
||||
'https://rawcdn.githack.com/googlefonts/noto-fonts/ffebf8c1ee449e544955a7e813c54f9b73848eac/hinted/ttf/NotoSansTelugu/NotoSansTelugu-Regular.ttf',
|
||||
'Noto Sans Sinhala':
|
||||
'https://raw.githack.com/googlefonts/noto-fonts/main/hinted/ttf/NotoSansSinhala/NotoSansSinhala-Regular.ttf',
|
||||
'https://rawcdn.githack.com/googlefonts/noto-fonts/ffebf8c1ee449e544955a7e813c54f9b73848eac/hinted/ttf/NotoSansSinhala/NotoSansSinhala-Regular.ttf',
|
||||
'Noto Sans Thai':
|
||||
'https://raw.githack.com/googlefonts/noto-fonts/main/hinted/ttf/NotoSansThai/NotoSansThai-Regular.ttf',
|
||||
'https://rawcdn.githack.com/googlefonts/noto-fonts/ffebf8c1ee449e544955a7e813c54f9b73848eac/hinted/ttf/NotoSansThai/NotoSansThai-Regular.ttf',
|
||||
'Noto Sans Khmer':
|
||||
'https://raw.githack.com/googlefonts/noto-fonts/main/hinted/ttf/NotoSansKhmer/NotoSansKhmer-Regular.ttf',
|
||||
'https://rawcdn.githack.com/googlefonts/noto-fonts/ffebf8c1ee449e544955a7e813c54f9b73848eac/hinted/ttf/NotoSansKhmer/NotoSansKhmer-Regular.ttf',
|
||||
'Noto Sans Lao':
|
||||
'https://raw.githack.com/googlefonts/noto-fonts/main/hinted/ttf/NotoSansLao/NotoSansLao-Regular.ttf',
|
||||
'https://rawcdn.githack.com/googlefonts/noto-fonts/ffebf8c1ee449e544955a7e813c54f9b73848eac/hinted/ttf/NotoSansLao/NotoSansLao-Regular.ttf',
|
||||
'Noto Sans Myanmar':
|
||||
'https://raw.githack.com/googlefonts/noto-fonts/main/hinted/ttf/NotoSansMyanmar/NotoSansMyanmar-Regular.ttf',
|
||||
'https://rawcdn.githack.com/googlefonts/noto-fonts/ffebf8c1ee449e544955a7e813c54f9b73848eac/hinted/ttf/NotoSansMyanmar/NotoSansMyanmar-Regular.ttf',
|
||||
'Noto Sans Hebrew':
|
||||
'https://raw.githack.com/googlefonts/noto-fonts/main/hinted/ttf/NotoSansHebrew/NotoSansHebrew-Regular.ttf',
|
||||
'https://rawcdn.githack.com/googlefonts/noto-fonts/ffebf8c1ee449e544955a7e813c54f9b73848eac/hinted/ttf/NotoSansHebrew/NotoSansHebrew-Regular.ttf',
|
||||
'Noto Sans Georgian':
|
||||
'https://raw.githack.com/googlefonts/noto-fonts/main/hinted/ttf/NotoSansGeorgian/NotoSansGeorgian-Regular.ttf',
|
||||
'https://rawcdn.githack.com/googlefonts/noto-fonts/ffebf8c1ee449e544955a7e813c54f9b73848eac/hinted/ttf/NotoSansGeorgian/NotoSansGeorgian-Regular.ttf',
|
||||
'Noto Sans Ethiopic':
|
||||
'https://raw.githack.com/googlefonts/noto-fonts/main/hinted/ttf/NotoSansEthiopic/NotoSansEthiopic-Regular.ttf',
|
||||
'https://rawcdn.githack.com/googlefonts/noto-fonts/ffebf8c1ee449e544955a7e813c54f9b73848eac/hinted/ttf/NotoSansEthiopic/NotoSansEthiopic-Regular.ttf',
|
||||
'Noto Serif Tibetan':
|
||||
'https://raw.githack.com/googlefonts/noto-fonts/main/hinted/ttf/NotoSerifTibetan/NotoSerifTibetan-Regular.ttf',
|
||||
'https://rawcdn.githack.com/googlefonts/noto-fonts/ffebf8c1ee449e544955a7e813c54f9b73848eac/hinted/ttf/NotoSerifTibetan/NotoSerifTibetan-Regular.ttf',
|
||||
'Noto Sans Cherokee':
|
||||
'https://raw.githack.com/googlefonts/noto-fonts/main/hinted/ttf/NotoSansCherokee/NotoSansCherokee-Regular.ttf',
|
||||
'https://rawcdn.githack.com/googlefonts/noto-fonts/ffebf8c1ee449e544955a7e813c54f9b73848eac/hinted/ttf/NotoSansCherokee/NotoSansCherokee-Regular.ttf',
|
||||
'Noto Sans Armenian':
|
||||
'https://raw.githack.com/googlefonts/noto-fonts/main/hinted/ttf/NotoSansArmenian/NotoSansArmenian-Regular.ttf',
|
||||
'https://rawcdn.githack.com/googlefonts/noto-fonts/ffebf8c1ee449e544955a7e813c54f9b73848eac/hinted/ttf/NotoSansArmenian/NotoSansArmenian-Regular.ttf',
|
||||
'Noto Sans Syriac':
|
||||
'https://raw.githack.com/googlefonts/noto-fonts/main/hinted/ttf/NotoSansSyriac/NotoSansSyriac-Regular.ttf',
|
||||
'https://rawcdn.githack.com/googlefonts/noto-fonts/ffebf8c1ee449e544955a7e813c54f9b73848eac/hinted/ttf/NotoSansSyriac/NotoSansSyriac-Regular.ttf',
|
||||
'Noto Sans':
|
||||
'https://raw.githack.com/googlefonts/noto-fonts/main/hinted/ttf/NotoSans/NotoSans-Regular.ttf',
|
||||
'https://rawcdn.githack.com/googlefonts/noto-fonts/ffebf8c1ee449e544955a7e813c54f9b73848eac/hinted/ttf/NotoSans/NotoSans-Regular.ttf',
|
||||
};
|
||||
|
||||
export function getFontUrlForFamily(fontFamily: string): string {
|
||||
|
||||
+5
-3
@@ -23,6 +23,7 @@ export const supportedLanguages = [
|
||||
'ko',
|
||||
'ja',
|
||||
'uk',
|
||||
'sk',
|
||||
] as const;
|
||||
export type SupportedLanguage = (typeof supportedLanguages)[number];
|
||||
|
||||
@@ -47,6 +48,7 @@ export const languageNames: Record<SupportedLanguage, string> = {
|
||||
ko: '한국어',
|
||||
ja: '日本語',
|
||||
uk: 'Українська',
|
||||
sk: 'Slovenčina',
|
||||
};
|
||||
|
||||
export const getLanguageFromUrl = (): SupportedLanguage => {
|
||||
@@ -62,7 +64,7 @@ export const getLanguageFromUrl = (): SupportedLanguage => {
|
||||
}
|
||||
|
||||
const langMatch = path.match(
|
||||
/^\/(en|ar|fr|es|de|zh|zh-TW|vi|tr|id|it|pt|nl|be|da|ko|sv|ru|ja|uk)(?:\/|$)/
|
||||
/^\/(en|ar|fr|es|de|zh|zh-TW|vi|tr|id|it|pt|nl|be|da|ko|sv|ru|ja|uk|sk)(?:\/|$)/
|
||||
);
|
||||
if (
|
||||
langMatch &&
|
||||
@@ -152,7 +154,7 @@ export const changeLanguage = (lang: SupportedLanguage): void => {
|
||||
|
||||
let pagePathWithoutLang = relativePath;
|
||||
const langPrefixMatch = relativePath.match(
|
||||
/^\/(en|ar|fr|es|de|zh|zh-TW|vi|tr|id|it|pt|nl|be|da|ko|sv|ru|ja|uk)(\/.*)?$/
|
||||
/^\/(en|ar|fr|es|de|zh|zh-TW|vi|tr|id|it|pt|nl|be|da|ko|sv|ru|ja|uk|sk)(\/.*)?$/
|
||||
);
|
||||
if (langPrefixMatch) {
|
||||
pagePathWithoutLang = langPrefixMatch[2] || '/';
|
||||
@@ -247,7 +249,7 @@ export const rewriteLinks = (): void => {
|
||||
}
|
||||
|
||||
const langPrefixRegex = new RegExp(
|
||||
`^(${basePath.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')})?/?(en|ar|fr|es|de|zh|zh-TW|vi|tr|id|it|pt|nl|be|da|ko|sv|ru|ja|uk)(/|$)`
|
||||
`^(${basePath.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')})?/?(en|ar|fr|es|de|zh|zh-TW|vi|tr|id|it|pt|nl|be|da|ko|sv|ru|ja|uk|sk)(/|$)`
|
||||
);
|
||||
if (langPrefixRegex.test(href)) {
|
||||
return;
|
||||
|
||||
+142
-48
@@ -12,6 +12,8 @@ import {
|
||||
} from '../utils/render-utils';
|
||||
import { initializeGlobalShortcuts } from '../utils/shortcuts-init.js';
|
||||
import { repairPdfFile } from './repair-pdf.js';
|
||||
import { partitionIncomingFiles } from '../utils/multi-tool-file-input.js';
|
||||
import { convertImagesToPdfFile } from '../utils/images-to-pdf-lib.js';
|
||||
|
||||
pdfjsLib.GlobalWorkerOptions.workerSrc = new URL(
|
||||
'pdfjs-dist/build/pdf.worker.min.mjs',
|
||||
@@ -101,7 +103,11 @@ function showModal(
|
||||
success: 'text-green-400',
|
||||
};
|
||||
|
||||
modalIcon.innerHTML = `<i data-lucide="${iconMap[type]}" class="w-12 h-12 ${colorMap[type]}"></i>`;
|
||||
modalIcon.replaceChildren();
|
||||
const iconEl = document.createElement('i');
|
||||
iconEl.dataset.lucide = iconMap[type];
|
||||
iconEl.className = `w-12 h-12 ${colorMap[type]}`;
|
||||
modalIcon.appendChild(iconEl);
|
||||
modal.classList.remove('hidden');
|
||||
createIcons({ icons });
|
||||
}
|
||||
@@ -133,20 +139,25 @@ async function withButtonLoading(
|
||||
const button = document.getElementById(buttonId) as HTMLButtonElement;
|
||||
if (!button) return;
|
||||
|
||||
const originalContent = button.innerHTML;
|
||||
const originalChildren = Array.from(button.childNodes).map((n) =>
|
||||
n.cloneNode(true)
|
||||
);
|
||||
const originalPointerEvents = button.style.pointerEvents;
|
||||
|
||||
try {
|
||||
button.disabled = true;
|
||||
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>';
|
||||
const spinner = document.createElement('i');
|
||||
spinner.dataset.lucide = 'loader';
|
||||
spinner.className = 'w-5 h-5 animate-spin text-white';
|
||||
button.replaceChildren(spinner);
|
||||
createIcons({ icons });
|
||||
|
||||
await action();
|
||||
} finally {
|
||||
button.disabled = false;
|
||||
button.style.pointerEvents = originalPointerEvents;
|
||||
button.innerHTML = originalContent;
|
||||
button.replaceChildren(...originalChildren);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -330,33 +341,122 @@ function initializeTool() {
|
||||
}
|
||||
});
|
||||
|
||||
const uploadArea = document.getElementById('upload-area');
|
||||
if (uploadArea) {
|
||||
uploadArea.addEventListener('dragover', (e) => {
|
||||
e.preventDefault();
|
||||
uploadArea.classList.add('border-indigo-500');
|
||||
});
|
||||
uploadArea.addEventListener('dragleave', () => {
|
||||
uploadArea.classList.remove('border-indigo-500');
|
||||
});
|
||||
uploadArea.addEventListener('drop', (e) => {
|
||||
e.preventDefault();
|
||||
uploadArea.classList.remove('border-indigo-500');
|
||||
const files = Array.from(e.dataTransfer?.files || []).filter(
|
||||
(f) => f.type === 'application/pdf'
|
||||
);
|
||||
if (files.length > 0) {
|
||||
loadPdfs(files);
|
||||
}
|
||||
});
|
||||
}
|
||||
setupGlobalDragAndDrop();
|
||||
|
||||
document.getElementById('upload-area')?.classList.remove('hidden');
|
||||
}
|
||||
|
||||
let dragOverlay: HTMLDivElement | null = null;
|
||||
let isProcessingDrop = false;
|
||||
|
||||
function showDragOverlay() {
|
||||
if (dragOverlay) return;
|
||||
dragOverlay = document.createElement('div');
|
||||
dragOverlay.className =
|
||||
'fixed inset-0 bg-indigo-900/40 border-4 border-dashed border-indigo-400 z-40 pointer-events-none flex items-center justify-center';
|
||||
const inner = document.createElement('div');
|
||||
inner.className =
|
||||
'text-white text-lg sm:text-xl font-semibold bg-gray-900/80 px-6 py-4 rounded-lg text-center';
|
||||
inner.textContent = t('multiTool.dropToAdd');
|
||||
dragOverlay.appendChild(inner);
|
||||
document.body.appendChild(dragOverlay);
|
||||
}
|
||||
|
||||
function hideDragOverlay() {
|
||||
if (dragOverlay) {
|
||||
dragOverlay.remove();
|
||||
dragOverlay = null;
|
||||
}
|
||||
}
|
||||
|
||||
function setupGlobalDragAndDrop() {
|
||||
const target = document.getElementById('main-scroll-container');
|
||||
if (!target) return;
|
||||
|
||||
target.addEventListener('dragover', (e) => {
|
||||
if (!e.dataTransfer?.types.includes('Files')) return;
|
||||
e.preventDefault();
|
||||
showDragOverlay();
|
||||
});
|
||||
|
||||
document.addEventListener('dragleave', (e) => {
|
||||
if (
|
||||
e.clientX <= 0 ||
|
||||
e.clientY <= 0 ||
|
||||
e.clientX >= window.innerWidth ||
|
||||
e.clientY >= window.innerHeight
|
||||
) {
|
||||
hideDragOverlay();
|
||||
}
|
||||
});
|
||||
|
||||
document.addEventListener('drop', () => {
|
||||
hideDragOverlay();
|
||||
});
|
||||
|
||||
target.addEventListener('drop', (e) => {
|
||||
e.preventDefault();
|
||||
hideDragOverlay();
|
||||
const files = Array.from(e.dataTransfer?.files || []);
|
||||
if (files.length === 0) return;
|
||||
void handleIncomingFiles(files);
|
||||
});
|
||||
}
|
||||
|
||||
async function processIncomingFiles(rawFiles: File[]): Promise<File[]> {
|
||||
const { pdfFiles, imageFiles, skipped } = partitionIncomingFiles(rawFiles);
|
||||
const result: File[] = [...pdfFiles];
|
||||
|
||||
if (imageFiles.length > 0) {
|
||||
try {
|
||||
showLoading(0, 100);
|
||||
const loadingText = document.getElementById('loading-text');
|
||||
if (loadingText)
|
||||
loadingText.textContent = t('multiTool.convertingImages');
|
||||
const imagesPdfFile = await convertImagesToPdfFile(imageFiles);
|
||||
result.push(imagesPdfFile);
|
||||
} catch (e) {
|
||||
console.error('Failed to convert images to PDF:', e);
|
||||
hideLoading();
|
||||
showModal(
|
||||
t('multiTool.error'),
|
||||
t('multiTool.failedToConvertImages'),
|
||||
'error'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (skipped.length > 0) {
|
||||
console.warn('Skipped unsupported files:', skipped);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
async function handleIncomingFiles(rawFiles: File[]) {
|
||||
if (isRendering || isProcessingDrop) {
|
||||
showModal(t('multiTool.pleaseWait'), t('multiTool.pagesRendering'), 'info');
|
||||
return;
|
||||
}
|
||||
|
||||
isProcessingDrop = true;
|
||||
try {
|
||||
const processed = await processIncomingFiles(rawFiles);
|
||||
if (processed.length > 0) {
|
||||
await loadPdfs(processed);
|
||||
} else {
|
||||
hideLoading();
|
||||
}
|
||||
} finally {
|
||||
isProcessingDrop = false;
|
||||
}
|
||||
}
|
||||
|
||||
function resetAll() {
|
||||
renderCancelled = true;
|
||||
isRendering = false;
|
||||
isProcessingDrop = false;
|
||||
hideDragOverlay();
|
||||
snapshot();
|
||||
allPages = [];
|
||||
selectedPages.clear();
|
||||
@@ -382,7 +482,7 @@ async function handlePdfUpload(e: Event) {
|
||||
const input = e.target as HTMLInputElement;
|
||||
const files = Array.from(input.files || []);
|
||||
if (files.length > 0) {
|
||||
await loadPdfs(files);
|
||||
await handleIncomingFiles(files);
|
||||
}
|
||||
input.value = '';
|
||||
}
|
||||
@@ -524,20 +624,6 @@ async function loadPdfs(files: File[]) {
|
||||
}
|
||||
}
|
||||
|
||||
function getCacheKey(pdfIndex: number, pageIndex: number): string {
|
||||
return `${pdfIndex}-${pageIndex}`;
|
||||
}
|
||||
|
||||
// Wrapper for compatibility with updatePageDisplay
|
||||
function createPageCard(pageData: PageData, index: number) {
|
||||
const pagesContainer = document.getElementById('pages-container');
|
||||
if (!pagesContainer) return;
|
||||
|
||||
const card = createPageElement(pageData.canvas, index);
|
||||
pagesContainer.appendChild(card);
|
||||
createIcons({ icons });
|
||||
}
|
||||
|
||||
// Modified to return the element instead of appending it
|
||||
function createPageElement(
|
||||
canvas: HTMLCanvasElement | null,
|
||||
@@ -584,10 +670,13 @@ function createPageElement(
|
||||
const loading = document.createElement('div');
|
||||
loading.className =
|
||||
'flex flex-col items-center justify-center text-gray-400';
|
||||
loading.innerHTML = `
|
||||
<i data-lucide="loader" class="w-8 h-8 animate-spin mb-2"></i>
|
||||
<span class="text-xs">${t('common.loading')}</span>
|
||||
`;
|
||||
const loadingIcon = document.createElement('i');
|
||||
loadingIcon.dataset.lucide = 'loader';
|
||||
loadingIcon.className = 'w-8 h-8 animate-spin mb-2';
|
||||
const loadingLabel = document.createElement('span');
|
||||
loadingLabel.className = 'text-xs';
|
||||
loadingLabel.textContent = t('common.loading');
|
||||
loading.append(loadingIcon, loadingLabel);
|
||||
preview.appendChild(loading);
|
||||
preview.classList.add('bg-gray-700'); // Darker background for loading
|
||||
}
|
||||
@@ -611,9 +700,15 @@ function createPageElement(
|
||||
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.innerHTML = selectedPages.has(index)
|
||||
? '<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>';
|
||||
const selectIcon = document.createElement('i');
|
||||
if (selectedPages.has(index)) {
|
||||
selectIcon.dataset.lucide = 'check-square';
|
||||
selectIcon.className = 'w-4 h-4 text-indigo-400';
|
||||
} else {
|
||||
selectIcon.dataset.lucide = 'square';
|
||||
selectIcon.className = 'w-4 h-4 text-gray-200';
|
||||
}
|
||||
selectBtn.appendChild(selectIcon);
|
||||
selectBtn.onclick = (e) => {
|
||||
e.stopPropagation();
|
||||
toggleSelectOptimized(index);
|
||||
@@ -1094,7 +1189,6 @@ async function downloadAll() {
|
||||
async function downloadSplitPdfs() {
|
||||
try {
|
||||
const zip = new JSZip();
|
||||
const sortedMarkers = Array.from(splitMarkers).sort((a, b) => a - b);
|
||||
|
||||
// Create segments based on split markers
|
||||
const segments: number[][] = [];
|
||||
|
||||
+115
-142
@@ -2,7 +2,12 @@ import { showLoader, hideLoader, showAlert } from '../ui.js';
|
||||
import { t } from '../i18n/i18n';
|
||||
import { createIcons, icons } from 'lucide';
|
||||
import * as pdfjsLib from 'pdfjs-dist';
|
||||
import { downloadFile, getPDFDocument, formatBytes } from '../utils/helpers.js';
|
||||
import {
|
||||
downloadFile,
|
||||
getPDFDocument,
|
||||
formatBytes,
|
||||
initializeQpdf,
|
||||
} from '../utils/helpers.js';
|
||||
import { loadPdfWithPasswordPrompt } from '../utils/password-prompt.js';
|
||||
import { state } from '../state.js';
|
||||
import {
|
||||
@@ -13,8 +18,18 @@ import { initPagePreview } from '../utils/page-preview.js';
|
||||
import { isCpdfAvailable } from '../utils/cpdf-helper.js';
|
||||
import { showWasmRequiredDialog } from '../utils/wasm-provider.js';
|
||||
import JSZip from 'jszip';
|
||||
import { PDFDocument as PDFLibDocument } from 'pdf-lib';
|
||||
import { loadPdfDocument } from '../utils/load-pdf-document.js';
|
||||
import type { QpdfInstanceExtended } from '@/types';
|
||||
import {
|
||||
parseRangeGroups,
|
||||
evenOddIndices,
|
||||
allPagesIndices,
|
||||
nTimesGroups,
|
||||
bookmarkSplitGroups,
|
||||
groupFilename,
|
||||
uniqueZipName,
|
||||
extractPagesWithQpdf,
|
||||
} from '../utils/split-pdf-helpers.js';
|
||||
|
||||
pdfjsLib.GlobalWorkerOptions.workerSrc = new URL(
|
||||
'pdfjs-dist/build/pdf.worker.min.mjs',
|
||||
@@ -23,6 +38,7 @@ pdfjsLib.GlobalWorkerOptions.workerSrc = new URL(
|
||||
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
let visualSelectorRendered = false;
|
||||
let isSplitting = false;
|
||||
|
||||
const fileInput = document.getElementById('file-input') as HTMLInputElement;
|
||||
const dropZone = document.getElementById('drop-zone');
|
||||
@@ -38,7 +54,8 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
const rangePanel = document.getElementById('range-panel');
|
||||
const visualPanel = document.getElementById('visual-select-panel');
|
||||
const evenOddPanel = document.getElementById('even-odd-panel');
|
||||
const zipOptionWrapper = document.getElementById('zip-option-wrapper');
|
||||
const outputModeWrapper = document.getElementById('output-mode-wrapper');
|
||||
const outputSeparateLabel = document.getElementById('output-separate-label');
|
||||
const allPagesPanel = document.getElementById('all-pages-panel');
|
||||
const bookmarksPanel = document.getElementById('bookmarks-panel');
|
||||
const nTimesPanel = document.getElementById('n-times-panel');
|
||||
@@ -252,6 +269,11 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
) as HTMLInputElement;
|
||||
if (nValueInput) nValueInput.value = '5';
|
||||
|
||||
const combineRadio = document.getElementById(
|
||||
'output-combine'
|
||||
) as HTMLInputElement;
|
||||
if (combineRadio) combineRadio.checked = true;
|
||||
|
||||
// Reset radio buttons to default (range)
|
||||
const rangeRadio = document.querySelector(
|
||||
'input[name="split-mode"][value="range"]'
|
||||
@@ -271,18 +293,50 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
};
|
||||
|
||||
const split = async () => {
|
||||
if (isSplitting) return;
|
||||
isSplitting = true;
|
||||
if (processBtn) (processBtn as HTMLButtonElement).disabled = true;
|
||||
|
||||
const splitMode = splitModeSelect.value;
|
||||
const downloadAsZip =
|
||||
(document.getElementById('download-as-zip') as HTMLInputElement)
|
||||
?.checked || false;
|
||||
const oneFilePerUnit =
|
||||
(
|
||||
document.querySelector(
|
||||
'input[name="split-output-mode"]:checked'
|
||||
) as HTMLInputElement | null
|
||||
)?.value === 'separate';
|
||||
|
||||
showLoader('Splitting PDF...');
|
||||
|
||||
let qpdf: QpdfInstanceExtended | null = null;
|
||||
const inputPath = '/split-input.pdf';
|
||||
|
||||
try {
|
||||
if (!state.pdfDoc) throw new Error('No PDF document loaded.');
|
||||
const srcDoc = state.pdfDoc;
|
||||
|
||||
const totalPages = state.pdfDoc.getPageCount();
|
||||
const totalPages = srcDoc.getPageCount();
|
||||
let indicesToExtract: number[] = [];
|
||||
let outputGroups: number[][] | null = null;
|
||||
|
||||
let sourceBytes: Uint8Array | null = null;
|
||||
const getSourceBytes = async (): Promise<Uint8Array> => {
|
||||
if (sourceBytes) return sourceBytes;
|
||||
sourceBytes = new Uint8Array(await srcDoc.save());
|
||||
return sourceBytes;
|
||||
};
|
||||
|
||||
const ensureQpdf = async (): Promise<QpdfInstanceExtended> => {
|
||||
if (qpdf) return qpdf;
|
||||
const instance = await initializeQpdf();
|
||||
instance.FS.writeFile(inputPath, await getSourceBytes());
|
||||
qpdf = instance;
|
||||
return instance;
|
||||
};
|
||||
|
||||
const extractPages = async (indices: number[]): Promise<Uint8Array> => {
|
||||
const instance = await ensureQpdf();
|
||||
return extractPagesWithQpdf(instance, inputPath, indices);
|
||||
};
|
||||
|
||||
switch (splitMode) {
|
||||
case 'range': {
|
||||
@@ -290,71 +344,12 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
document.getElementById('page-range') as HTMLInputElement
|
||||
).value;
|
||||
if (!pageRangeInput) throw new Error('Choose a valid page range.');
|
||||
const ranges = pageRangeInput.split(',');
|
||||
|
||||
const rangeGroups: number[][] = [];
|
||||
for (const range of ranges) {
|
||||
const trimmedRange = range.trim();
|
||||
if (!trimmedRange) continue;
|
||||
const { groups: rangeGroups, indices: rangeIndices } =
|
||||
parseRangeGroups(pageRangeInput, totalPages);
|
||||
indicesToExtract.push(...rangeIndices);
|
||||
|
||||
const groupIndices: number[] = [];
|
||||
if (trimmedRange.includes('-')) {
|
||||
const [start, end] = trimmedRange.split('-').map(Number);
|
||||
if (
|
||||
isNaN(start) ||
|
||||
isNaN(end) ||
|
||||
start < 1 ||
|
||||
end > totalPages ||
|
||||
start > end
|
||||
)
|
||||
continue;
|
||||
for (let i = start; i <= end; i++) groupIndices.push(i - 1);
|
||||
} else {
|
||||
const pageNum = Number(trimmedRange);
|
||||
if (isNaN(pageNum) || pageNum < 1 || pageNum > totalPages)
|
||||
continue;
|
||||
groupIndices.push(pageNum - 1);
|
||||
}
|
||||
|
||||
if (groupIndices.length > 0) {
|
||||
rangeGroups.push(groupIndices);
|
||||
indicesToExtract.push(...groupIndices);
|
||||
}
|
||||
}
|
||||
|
||||
if (rangeGroups.length > 1) {
|
||||
showLoader('Creating separate PDFs for each range...');
|
||||
const zip = new JSZip();
|
||||
|
||||
for (let i = 0; i < rangeGroups.length; i++) {
|
||||
const group = rangeGroups[i];
|
||||
const newPdf = await PDFLibDocument.create();
|
||||
const copiedPages = await newPdf.copyPages(state.pdfDoc, group);
|
||||
copiedPages.forEach((page) => newPdf.addPage(page));
|
||||
const pdfBytes = await newPdf.save();
|
||||
|
||||
const minPage = Math.min(...group) + 1;
|
||||
const maxPage = Math.max(...group) + 1;
|
||||
const filename =
|
||||
minPage === maxPage
|
||||
? `page-${minPage}.pdf`
|
||||
: `pages-${minPage}-${maxPage}.pdf`;
|
||||
zip.file(filename, pdfBytes);
|
||||
}
|
||||
|
||||
const zipBlob = await zip.generateAsync({ type: 'blob' });
|
||||
downloadFile(zipBlob, 'split-pages.zip');
|
||||
hideLoader();
|
||||
showAlert(
|
||||
'Success',
|
||||
`PDF split into ${rangeGroups.length} files successfully!`,
|
||||
'success',
|
||||
() => {
|
||||
resetState();
|
||||
}
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (oneFilePerUnit) outputGroups = rangeGroups;
|
||||
break;
|
||||
}
|
||||
|
||||
@@ -364,21 +359,20 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
) as HTMLInputElement;
|
||||
if (!choiceElement)
|
||||
throw new Error('Please select even or odd pages.');
|
||||
const choice = choiceElement.value;
|
||||
for (let i = 0; i < totalPages; i++) {
|
||||
if (choice === 'even' && (i + 1) % 2 === 0)
|
||||
indicesToExtract.push(i);
|
||||
if (choice === 'odd' && (i + 1) % 2 !== 0) indicesToExtract.push(i);
|
||||
}
|
||||
const choice = choiceElement.value === 'even' ? 'even' : 'odd';
|
||||
indicesToExtract = evenOddIndices(choice, totalPages);
|
||||
break;
|
||||
}
|
||||
case 'all':
|
||||
indicesToExtract = Array.from({ length: totalPages }, (_, i) => i);
|
||||
indicesToExtract = allPagesIndices(totalPages);
|
||||
outputGroups = indicesToExtract.map((i) => [i]);
|
||||
break;
|
||||
case 'visual':
|
||||
indicesToExtract = Array.from(
|
||||
document.querySelectorAll('.page-thumbnail-wrapper.selected')
|
||||
).map((el) => parseInt((el as HTMLElement).dataset.pageIndex || '0'));
|
||||
if (oneFilePerUnit)
|
||||
outputGroups = [...new Set(indicesToExtract)].map((i) => [i]);
|
||||
break;
|
||||
case 'bookmarks': {
|
||||
if (!isCpdfAvailable()) {
|
||||
@@ -388,8 +382,10 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
}
|
||||
const { getCpdf } = await import('../utils/cpdf-helper.js');
|
||||
const cpdf = await getCpdf();
|
||||
const pdfBytes = await state.pdfDoc.save();
|
||||
const pdf = cpdf.fromMemory(new Uint8Array(pdfBytes), '');
|
||||
const pdf = cpdf.fromMemory(
|
||||
new Uint8Array(await getSourceBytes()),
|
||||
''
|
||||
);
|
||||
|
||||
cpdf.startGetBookmarkInfo(pdf);
|
||||
const bookmarkCount = cpdf.numberBookmarks();
|
||||
@@ -415,27 +411,11 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
throw new Error('No bookmarks found at the selected level.');
|
||||
}
|
||||
|
||||
splitPages.sort((a, b) => a - b);
|
||||
const zip = new JSZip();
|
||||
const bookmarkGroups = bookmarkSplitGroups(splitPages, totalPages);
|
||||
|
||||
for (let i = 0; i < splitPages.length; i++) {
|
||||
const startPage = i === 0 ? 0 : splitPages[i];
|
||||
const endPage =
|
||||
i < splitPages.length - 1
|
||||
? splitPages[i + 1] - 1
|
||||
: totalPages - 1;
|
||||
|
||||
const newPdf = await PDFLibDocument.create();
|
||||
const pageIndices = Array.from(
|
||||
{ length: endPage - startPage + 1 },
|
||||
(_, idx) => startPage + idx
|
||||
);
|
||||
const copiedPages = await newPdf.copyPages(
|
||||
state.pdfDoc,
|
||||
pageIndices
|
||||
);
|
||||
copiedPages.forEach((page) => newPdf.addPage(page));
|
||||
const pdfBytes2 = await newPdf.save();
|
||||
for (let i = 0; i < bookmarkGroups.length; i++) {
|
||||
const pdfBytes2 = await extractPages(bookmarkGroups[i]);
|
||||
zip.file(`split-${i + 1}.pdf`, pdfBytes2);
|
||||
}
|
||||
|
||||
@@ -456,23 +436,10 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
if (nValue < 1) throw new Error('N must be at least 1.');
|
||||
|
||||
const zip2 = new JSZip();
|
||||
const numSplits = Math.ceil(totalPages / nValue);
|
||||
const chunks = nTimesGroups(nValue, totalPages);
|
||||
|
||||
for (let i = 0; i < numSplits; i++) {
|
||||
const startPage = i * nValue;
|
||||
const endPage = Math.min(startPage + nValue - 1, totalPages - 1);
|
||||
const pageIndices = Array.from(
|
||||
{ length: endPage - startPage + 1 },
|
||||
(_, idx) => startPage + idx
|
||||
);
|
||||
|
||||
const newPdf = await PDFLibDocument.create();
|
||||
const copiedPages = await newPdf.copyPages(
|
||||
state.pdfDoc,
|
||||
pageIndices
|
||||
);
|
||||
copiedPages.forEach((page) => newPdf.addPage(page));
|
||||
const pdfBytes3 = await newPdf.save();
|
||||
for (let i = 0; i < chunks.length; i++) {
|
||||
const pdfBytes3 = await extractPages(chunks[i]);
|
||||
zip2.file(`split-${i + 1}.pdf`, pdfBytes3);
|
||||
}
|
||||
|
||||
@@ -495,31 +462,26 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
throw new Error('No pages were selected for splitting.');
|
||||
}
|
||||
|
||||
if (
|
||||
splitMode === 'all' ||
|
||||
(['range', 'visual'].includes(splitMode) && downloadAsZip)
|
||||
) {
|
||||
showLoader('Creating ZIP file...');
|
||||
const zip = new JSZip();
|
||||
for (const index of uniqueIndices) {
|
||||
const newPdf = await PDFLibDocument.create();
|
||||
const [copiedPage] = await newPdf.copyPages(state.pdfDoc, [
|
||||
index as number,
|
||||
]);
|
||||
newPdf.addPage(copiedPage);
|
||||
const pdfBytes = await newPdf.save();
|
||||
zip.file(`page-${index + 1}.pdf`, new Uint8Array(pdfBytes));
|
||||
if (outputGroups && outputGroups.length > 0) {
|
||||
if (outputGroups.length === 1) {
|
||||
const pdfBytes = await extractPages(outputGroups[0]);
|
||||
downloadFile(
|
||||
new Blob([new Uint8Array(pdfBytes)], { type: 'application/pdf' }),
|
||||
groupFilename(outputGroups[0])
|
||||
);
|
||||
} else {
|
||||
showLoader('Creating ZIP file...');
|
||||
const zip = new JSZip();
|
||||
const usedNames = new Map<string, number>();
|
||||
for (const group of outputGroups) {
|
||||
const pdfBytes = await extractPages(group);
|
||||
zip.file(uniqueZipName(groupFilename(group), usedNames), pdfBytes);
|
||||
}
|
||||
const zipBlob = await zip.generateAsync({ type: 'blob' });
|
||||
downloadFile(zipBlob, 'split-pages.zip');
|
||||
}
|
||||
const zipBlob = await zip.generateAsync({ type: 'blob' });
|
||||
downloadFile(zipBlob, 'split-pages.zip');
|
||||
} else {
|
||||
const newPdf = await PDFLibDocument.create();
|
||||
const copiedPages = await newPdf.copyPages(
|
||||
state.pdfDoc,
|
||||
uniqueIndices as number[]
|
||||
);
|
||||
copiedPages.forEach((page) => newPdf.addPage(page));
|
||||
const pdfBytes = await newPdf.save();
|
||||
const pdfBytes = await extractPages(uniqueIndices);
|
||||
downloadFile(
|
||||
new Blob([new Uint8Array(pdfBytes)], { type: 'application/pdf' }),
|
||||
'split-document.pdf'
|
||||
@@ -542,6 +504,15 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
: 'Failed to split PDF. Please check your selection.'
|
||||
);
|
||||
} finally {
|
||||
if (qpdf) {
|
||||
try {
|
||||
qpdf.FS.unlink(inputPath);
|
||||
} catch (cleanupError) {
|
||||
console.warn('Failed to clean up qpdf input file:', cleanupError);
|
||||
}
|
||||
}
|
||||
isSplitting = false;
|
||||
if (processBtn) (processBtn as HTMLButtonElement).disabled = false;
|
||||
hideLoader();
|
||||
}
|
||||
};
|
||||
@@ -610,15 +581,19 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
allPagesPanel?.classList.add('hidden');
|
||||
bookmarksPanel?.classList.add('hidden');
|
||||
nTimesPanel?.classList.add('hidden');
|
||||
zipOptionWrapper?.classList.add('hidden');
|
||||
outputModeWrapper?.classList.add('hidden');
|
||||
if (nTimesWarning) nTimesWarning.classList.add('hidden');
|
||||
|
||||
if (mode === 'range') {
|
||||
rangePanel?.classList.remove('hidden');
|
||||
zipOptionWrapper?.classList.remove('hidden');
|
||||
outputModeWrapper?.classList.remove('hidden');
|
||||
if (outputSeparateLabel)
|
||||
outputSeparateLabel.textContent = 'One PDF per range';
|
||||
} else if (mode === 'visual') {
|
||||
visualPanel?.classList.remove('hidden');
|
||||
zipOptionWrapper?.classList.remove('hidden');
|
||||
outputModeWrapper?.classList.remove('hidden');
|
||||
if (outputSeparateLabel)
|
||||
outputSeparateLabel.textContent = 'One PDF per page';
|
||||
renderVisualSelector();
|
||||
} else if (mode === 'even-odd') {
|
||||
evenOddPanel?.classList.remove('hidden');
|
||||
@@ -626,10 +601,8 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
allPagesPanel?.classList.remove('hidden');
|
||||
} else if (mode === 'bookmarks') {
|
||||
bookmarksPanel?.classList.remove('hidden');
|
||||
zipOptionWrapper?.classList.remove('hidden');
|
||||
} else if (mode === 'n-times') {
|
||||
nTimesPanel?.classList.remove('hidden');
|
||||
zipOptionWrapper?.classList.remove('hidden');
|
||||
|
||||
const updateWarning = () => {
|
||||
if (!state.pdfDoc) return;
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
import { PDFDocument as PDFLibDocument } from 'pdf-lib';
|
||||
import { preprocessImageFile } from './image-input-utils.js';
|
||||
|
||||
export interface EmbeddableImage {
|
||||
bytes: Uint8Array;
|
||||
format: 'jpg' | 'png';
|
||||
}
|
||||
|
||||
export async function normalizeImageToEmbeddable(
|
||||
file: File
|
||||
): Promise<EmbeddableImage> {
|
||||
const processed = await preprocessImageFile(file);
|
||||
const name = processed.name.toLowerCase();
|
||||
const isJpg =
|
||||
processed.type === 'image/jpeg' ||
|
||||
name.endsWith('.jpg') ||
|
||||
name.endsWith('.jpeg');
|
||||
const isPng = processed.type === 'image/png' || name.endsWith('.png');
|
||||
|
||||
if (isJpg) {
|
||||
return {
|
||||
bytes: new Uint8Array(await processed.arrayBuffer()),
|
||||
format: 'jpg',
|
||||
};
|
||||
}
|
||||
if (isPng) {
|
||||
return {
|
||||
bytes: new Uint8Array(await processed.arrayBuffer()),
|
||||
format: 'png',
|
||||
};
|
||||
}
|
||||
|
||||
const url = URL.createObjectURL(processed);
|
||||
try {
|
||||
const img = await new Promise<HTMLImageElement>((resolve, reject) => {
|
||||
const i = new Image();
|
||||
i.onload = () => resolve(i);
|
||||
i.onerror = () =>
|
||||
reject(new Error(`Failed to decode image: ${processed.name}`));
|
||||
i.src = url;
|
||||
});
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = img.naturalWidth;
|
||||
canvas.height = img.naturalHeight;
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (!ctx) throw new Error('Canvas context unavailable');
|
||||
ctx.drawImage(img, 0, 0);
|
||||
const blob = await new Promise<Blob>((resolve, reject) => {
|
||||
canvas.toBlob(
|
||||
(b) => (b ? resolve(b) : reject(new Error('Canvas toBlob failed'))),
|
||||
'image/png'
|
||||
);
|
||||
});
|
||||
return { bytes: new Uint8Array(await blob.arrayBuffer()), format: 'png' };
|
||||
} finally {
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
}
|
||||
|
||||
export async function convertImagesToPdfFile(images: File[]): Promise<File> {
|
||||
const pdfDoc = await PDFLibDocument.create();
|
||||
for (const img of images) {
|
||||
const { bytes, format } = await normalizeImageToEmbeddable(img);
|
||||
const embedded =
|
||||
format === 'jpg'
|
||||
? await pdfDoc.embedJpg(bytes)
|
||||
: await pdfDoc.embedPng(bytes);
|
||||
const page = pdfDoc.addPage([embedded.width, embedded.height]);
|
||||
page.drawImage(embedded, {
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: embedded.width,
|
||||
height: embedded.height,
|
||||
});
|
||||
}
|
||||
const pdfBytes = await pdfDoc.save();
|
||||
const filename =
|
||||
images.length === 1
|
||||
? images[0].name.replace(/\.[^.]+$/, '.pdf')
|
||||
: `images-${Date.now()}.pdf`;
|
||||
return new File([new Uint8Array(pdfBytes)], filename, {
|
||||
type: 'application/pdf',
|
||||
});
|
||||
}
|
||||
@@ -299,6 +299,7 @@ export class MarkdownEditor {
|
||||
startOnLoad: false,
|
||||
theme: 'default',
|
||||
securityLevel: 'strict',
|
||||
htmlLabels: false,
|
||||
fontFamily:
|
||||
'-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif',
|
||||
});
|
||||
@@ -717,9 +718,10 @@ export class MarkdownEditor {
|
||||
|
||||
const wrapper = document.createElement('div');
|
||||
wrapper.className = 'mermaid-diagram';
|
||||
wrapper.innerHTML = DOMPurify.sanitize(svg, {
|
||||
USE_PROFILES: { svg: true, svgFilters: true },
|
||||
});
|
||||
// Mermaid already sanitizes SVG at securityLevel: 'strict' (including
|
||||
// foreignObject HTML labels). A second DOMPurify pass with the SVG-only
|
||||
// profile strips those labels and leaves empty flowchart nodes.
|
||||
wrapper.innerHTML = svg;
|
||||
|
||||
pre.replaceWith(wrapper);
|
||||
} catch (error) {
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
import { isValidImageFile } from './image-input-utils.js';
|
||||
|
||||
export interface PartitionedFiles {
|
||||
pdfFiles: File[];
|
||||
imageFiles: File[];
|
||||
skipped: string[];
|
||||
}
|
||||
|
||||
export function partitionIncomingFiles(rawFiles: File[]): PartitionedFiles {
|
||||
const pdfFiles: File[] = [];
|
||||
const imageFiles: File[] = [];
|
||||
const skipped: string[] = [];
|
||||
|
||||
for (const f of rawFiles) {
|
||||
if (f.type === 'application/pdf' || f.name.toLowerCase().endsWith('.pdf')) {
|
||||
pdfFiles.push(f);
|
||||
} else if (isValidImageFile(f)) {
|
||||
imageFiles.push(f);
|
||||
} else {
|
||||
skipped.push(f.name);
|
||||
}
|
||||
}
|
||||
|
||||
return { pdfFiles, imageFiles, skipped };
|
||||
}
|
||||
+4
-4
@@ -101,8 +101,8 @@ function drawOcrTextLayer(
|
||||
y: transform.y,
|
||||
font,
|
||||
size: transform.fontSize,
|
||||
color: rgb(1, 0, 0),
|
||||
opacity: 1,
|
||||
color: rgb(0, 0, 0),
|
||||
opacity: 0,
|
||||
rotate: lineRotation,
|
||||
});
|
||||
} catch {
|
||||
@@ -132,8 +132,8 @@ function drawOcrTextLayer(
|
||||
y: spaceTransform.y,
|
||||
font,
|
||||
size: spaceTransform.fontSize,
|
||||
color: rgb(1, 0, 0),
|
||||
opacity: 1,
|
||||
color: rgb(0, 0, 0),
|
||||
opacity: 0,
|
||||
rotate: lineRotation,
|
||||
});
|
||||
} catch {
|
||||
|
||||
@@ -0,0 +1,148 @@
|
||||
import type { QpdfInstanceExtended } from '@/types';
|
||||
|
||||
export function parseRangeGroups(
|
||||
input: string,
|
||||
totalPages: number
|
||||
): { groups: number[][]; indices: number[] } {
|
||||
const groups: number[][] = [];
|
||||
const indices: number[] = [];
|
||||
|
||||
for (const range of input.split(',')) {
|
||||
const trimmedRange = range.trim();
|
||||
if (!trimmedRange) continue;
|
||||
|
||||
const groupIndices: number[] = [];
|
||||
if (trimmedRange.includes('-')) {
|
||||
const [start, end] = trimmedRange.split('-').map(Number);
|
||||
if (
|
||||
isNaN(start) ||
|
||||
isNaN(end) ||
|
||||
start < 1 ||
|
||||
end > totalPages ||
|
||||
start > end
|
||||
)
|
||||
continue;
|
||||
for (let i = start; i <= end; i++) groupIndices.push(i - 1);
|
||||
} else {
|
||||
const pageNum = Number(trimmedRange);
|
||||
if (isNaN(pageNum) || pageNum < 1 || pageNum > totalPages) continue;
|
||||
groupIndices.push(pageNum - 1);
|
||||
}
|
||||
|
||||
if (groupIndices.length > 0) {
|
||||
groups.push(groupIndices);
|
||||
indices.push(...groupIndices);
|
||||
}
|
||||
}
|
||||
|
||||
return { groups, indices };
|
||||
}
|
||||
|
||||
export function evenOddIndices(
|
||||
choice: 'even' | 'odd',
|
||||
totalPages: number
|
||||
): number[] {
|
||||
const indices: number[] = [];
|
||||
for (let i = 0; i < totalPages; i++) {
|
||||
if (choice === 'even' && (i + 1) % 2 === 0) indices.push(i);
|
||||
if (choice === 'odd' && (i + 1) % 2 !== 0) indices.push(i);
|
||||
}
|
||||
return indices;
|
||||
}
|
||||
|
||||
export function allPagesIndices(totalPages: number): number[] {
|
||||
return Array.from({ length: totalPages }, (_, i) => i);
|
||||
}
|
||||
|
||||
export function nTimesGroups(nValue: number, totalPages: number): number[][] {
|
||||
const groups: number[][] = [];
|
||||
const numSplits = Math.ceil(totalPages / nValue);
|
||||
for (let i = 0; i < numSplits; i++) {
|
||||
const startPage = i * nValue;
|
||||
const endPage = Math.min(startPage + nValue - 1, totalPages - 1);
|
||||
groups.push(
|
||||
Array.from(
|
||||
{ length: endPage - startPage + 1 },
|
||||
(_, idx) => startPage + idx
|
||||
)
|
||||
);
|
||||
}
|
||||
return groups;
|
||||
}
|
||||
|
||||
export function bookmarkSplitGroups(
|
||||
splitPages: number[],
|
||||
totalPages: number
|
||||
): number[][] {
|
||||
const sorted = [...splitPages].sort((a, b) => a - b);
|
||||
const groups: number[][] = [];
|
||||
for (let i = 0; i < sorted.length; i++) {
|
||||
const startPage = i === 0 ? 0 : sorted[i];
|
||||
const endPage = i < sorted.length - 1 ? sorted[i + 1] - 1 : totalPages - 1;
|
||||
groups.push(
|
||||
Array.from(
|
||||
{ length: endPage - startPage + 1 },
|
||||
(_, idx) => startPage + idx
|
||||
)
|
||||
);
|
||||
}
|
||||
return groups;
|
||||
}
|
||||
|
||||
export function groupFilename(group: number[]): string {
|
||||
const minPage = Math.min(...group) + 1;
|
||||
const maxPage = Math.max(...group) + 1;
|
||||
return minPage === maxPage
|
||||
? `page-${minPage}.pdf`
|
||||
: `pages-${minPage}-${maxPage}.pdf`;
|
||||
}
|
||||
|
||||
export function uniqueZipName(
|
||||
name: string,
|
||||
usedNames: Map<string, number>
|
||||
): string {
|
||||
const seen = usedNames.get(name);
|
||||
if (seen !== undefined) {
|
||||
usedNames.set(name, seen + 1);
|
||||
return name.replace(/\.pdf$/, `-${seen + 1}.pdf`);
|
||||
}
|
||||
usedNames.set(name, 0);
|
||||
return name;
|
||||
}
|
||||
|
||||
export function pagesToSpec(indices: number[]): string {
|
||||
return indices.map((index) => index + 1).join(',');
|
||||
}
|
||||
|
||||
export function extractPagesWithQpdf(
|
||||
qpdf: QpdfInstanceExtended,
|
||||
inputPath: string,
|
||||
indices: number[],
|
||||
outputPath = '/split-output.pdf'
|
||||
): Uint8Array {
|
||||
const exitCode = qpdf.callMain([
|
||||
inputPath,
|
||||
'--remove-unreferenced-resources=yes',
|
||||
'--pages',
|
||||
'.',
|
||||
pagesToSpec(indices),
|
||||
'--',
|
||||
outputPath,
|
||||
]);
|
||||
try {
|
||||
if (exitCode !== 0 && exitCode !== 3) {
|
||||
throw new Error(`Failed to extract pages (qpdf exit code ${exitCode}).`);
|
||||
}
|
||||
const bytes = qpdf.FS.readFile(outputPath, { encoding: 'binary' });
|
||||
if (!bytes || bytes.length === 0) {
|
||||
throw new Error('Page extraction produced an empty PDF.');
|
||||
}
|
||||
return bytes;
|
||||
} finally {
|
||||
try {
|
||||
qpdf.FS.unlink(outputPath);
|
||||
} catch (cleanupError) {
|
||||
console.warn('Failed to clean up qpdf output file:', cleanupError);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -383,18 +383,18 @@
|
||||
class="text-gray-300 text-base sm:text-lg mb-1 sm:mb-2"
|
||||
data-i18n="multiTool.uploadPdfFiles"
|
||||
>
|
||||
Select PDF Files
|
||||
Select PDF or image files
|
||||
</p>
|
||||
<p
|
||||
class="text-gray-400 text-xs sm:text-sm mb-3 sm:mb-4"
|
||||
data-i18n="multiTool.dragAndDrop"
|
||||
>
|
||||
Drag and drop PDF files here, or click to select
|
||||
Drag and drop PDF or image files here, or click to select
|
||||
</p>
|
||||
<input
|
||||
type="file"
|
||||
id="pdf-file-input"
|
||||
accept="application/pdf"
|
||||
accept="application/pdf,image/*,.heic,.heif,.jpg,.jpeg,.png,.bmp,.gif,.tiff,.tif,.webp,.svg,.psd,.jp2,.jpx,.jxr,.pnm,.pgm,.pbm,.ppm,.pam"
|
||||
multiple
|
||||
class="hidden"
|
||||
/>
|
||||
|
||||
+35
-11
@@ -359,17 +359,41 @@
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div id="zip-option-wrapper" class="mt-4 flex items-center">
|
||||
<input
|
||||
id="download-as-zip"
|
||||
type="checkbox"
|
||||
class="w-4 h-4 text-indigo-600 bg-gray-700 border-gray-600 rounded focus:ring-indigo-600 ring-offset-gray-800 focus:ring-2"
|
||||
/>
|
||||
<label
|
||||
for="download-as-zip"
|
||||
class="ml-2 text-sm font-medium text-gray-300"
|
||||
>Download as ZIP (for multiple files)</label
|
||||
>
|
||||
<div id="output-mode-wrapper" class="mt-4">
|
||||
<p class="block mb-2 text-sm font-medium text-gray-300">Output</p>
|
||||
<div class="flex items-center mb-2">
|
||||
<input
|
||||
id="output-combine"
|
||||
type="radio"
|
||||
value="combine"
|
||||
name="split-output-mode"
|
||||
checked
|
||||
class="w-4 h-4 text-indigo-600 bg-gray-700 border-gray-600 focus:ring-indigo-600 ring-offset-gray-800 focus:ring-2"
|
||||
/>
|
||||
<label
|
||||
for="output-combine"
|
||||
class="ml-2 text-sm font-medium text-gray-300"
|
||||
>Single combined PDF</label
|
||||
>
|
||||
</div>
|
||||
<div class="flex items-center">
|
||||
<input
|
||||
id="output-separate"
|
||||
type="radio"
|
||||
value="separate"
|
||||
name="split-output-mode"
|
||||
class="w-4 h-4 text-indigo-600 bg-gray-700 border-gray-600 focus:ring-indigo-600 ring-offset-gray-800 focus:ring-2"
|
||||
/>
|
||||
<label
|
||||
for="output-separate"
|
||||
id="output-separate-label"
|
||||
class="ml-2 text-sm font-medium text-gray-300"
|
||||
>One PDF per range</label
|
||||
>
|
||||
</div>
|
||||
<p class="mt-2 text-xs text-gray-400">
|
||||
Multiple files are downloaded together as a ZIP.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<button id="process-btn" class="btn-gradient w-full mt-6">
|
||||
|
||||
@@ -6,7 +6,7 @@ import { resolveFontUrl } from '../js/utils/font-loader';
|
||||
describe('font-loader', () => {
|
||||
it('uses the default public font URL when no offline font base URL is configured', () => {
|
||||
expect(resolveFontUrl('Noto Sans', {})).toBe(
|
||||
'https://raw.githack.com/googlefonts/noto-fonts/main/hinted/ttf/NotoSans/NotoSans-Regular.ttf'
|
||||
'https://rawcdn.githack.com/googlefonts/noto-fonts/ffebf8c1ee449e544955a7e813c54f9b73848eac/hinted/ttf/NotoSans/NotoSans-Regular.ttf'
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,270 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
|
||||
vi.mock('heic2any', () => ({
|
||||
default: vi.fn(async ({ blob }: { blob: Blob }) => {
|
||||
const bytes = new Uint8Array(await blob.arrayBuffer());
|
||||
return new Blob([bytes], { type: 'image/png' });
|
||||
}),
|
||||
}));
|
||||
|
||||
const mockEmbedJpg = vi.fn();
|
||||
const mockEmbedPng = vi.fn();
|
||||
const mockDrawImage = vi.fn();
|
||||
const mockAddPage = vi.fn();
|
||||
const mockSave = vi.fn();
|
||||
|
||||
vi.mock('pdf-lib', () => {
|
||||
return {
|
||||
PDFDocument: {
|
||||
create: vi.fn(async () => ({
|
||||
embedJpg: mockEmbedJpg,
|
||||
embedPng: mockEmbedPng,
|
||||
addPage: mockAddPage,
|
||||
save: mockSave,
|
||||
})),
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
import {
|
||||
normalizeImageToEmbeddable,
|
||||
convertImagesToPdfFile,
|
||||
} from '../js/utils/images-to-pdf-lib';
|
||||
|
||||
function makeFile(name: string, type: string, bytes = [1, 2, 3]): File {
|
||||
return new File([new Uint8Array(bytes)], name, { type });
|
||||
}
|
||||
|
||||
describe('normalizeImageToEmbeddable', () => {
|
||||
beforeEach(() => {
|
||||
mockEmbedJpg.mockReset();
|
||||
mockEmbedPng.mockReset();
|
||||
mockDrawImage.mockReset();
|
||||
mockAddPage.mockReset();
|
||||
mockSave.mockReset();
|
||||
});
|
||||
|
||||
it('passes through JPG bytes unchanged when MIME is image/jpeg', async () => {
|
||||
const file = makeFile('photo.jpg', 'image/jpeg', [0xff, 0xd8, 0xff, 0xe0]);
|
||||
const result = await normalizeImageToEmbeddable(file);
|
||||
expect(result.format).toBe('jpg');
|
||||
expect(Array.from(result.bytes)).toEqual([0xff, 0xd8, 0xff, 0xe0]);
|
||||
});
|
||||
|
||||
it('passes through JPG by extension when MIME is missing', async () => {
|
||||
const file = makeFile('photo.jpeg', '', [0xff, 0xd8]);
|
||||
const result = await normalizeImageToEmbeddable(file);
|
||||
expect(result.format).toBe('jpg');
|
||||
});
|
||||
|
||||
it('passes through PNG bytes unchanged when MIME is image/png', async () => {
|
||||
const file = makeFile('scan.png', 'image/png', [0x89, 0x50, 0x4e, 0x47]);
|
||||
const result = await normalizeImageToEmbeddable(file);
|
||||
expect(result.format).toBe('png');
|
||||
expect(Array.from(result.bytes)).toEqual([0x89, 0x50, 0x4e, 0x47]);
|
||||
});
|
||||
|
||||
it('passes through PNG by uppercase extension when MIME is missing', async () => {
|
||||
const file = makeFile('SCAN.PNG', '', [0x89, 0x50]);
|
||||
const result = await normalizeImageToEmbeddable(file);
|
||||
expect(result.format).toBe('png');
|
||||
});
|
||||
|
||||
describe('canvas-decode path (non-JPG/PNG formats)', () => {
|
||||
const originalCreateElement = document.createElement.bind(document);
|
||||
const originalImage = globalThis.Image;
|
||||
const originalCreateObjectURL = URL.createObjectURL;
|
||||
const originalRevokeObjectURL = URL.revokeObjectURL;
|
||||
const revokeSpy = vi.fn();
|
||||
|
||||
beforeEach(() => {
|
||||
revokeSpy.mockReset();
|
||||
|
||||
URL.createObjectURL = vi.fn(() => 'blob:test');
|
||||
URL.revokeObjectURL = revokeSpy;
|
||||
|
||||
globalThis.Image = class {
|
||||
naturalWidth = 100;
|
||||
naturalHeight = 50;
|
||||
onload: (() => void) | null = null;
|
||||
onerror: (() => void) | null = null;
|
||||
set src(_: string) {
|
||||
setTimeout(() => this.onload?.(), 0);
|
||||
}
|
||||
} as unknown as typeof Image;
|
||||
|
||||
document.createElement = ((tagName: string) => {
|
||||
if (tagName !== 'canvas') {
|
||||
return originalCreateElement(tagName);
|
||||
}
|
||||
return {
|
||||
width: 0,
|
||||
height: 0,
|
||||
getContext: vi.fn(() => ({ drawImage: vi.fn() })),
|
||||
toBlob: vi.fn((cb: (b: Blob) => void) => {
|
||||
cb(new Blob([new Uint8Array([0x89, 0x50])], { type: 'image/png' }));
|
||||
}),
|
||||
} as unknown as HTMLCanvasElement;
|
||||
}) as typeof document.createElement;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
document.createElement = originalCreateElement;
|
||||
globalThis.Image = originalImage;
|
||||
URL.createObjectURL = originalCreateObjectURL;
|
||||
URL.revokeObjectURL = originalRevokeObjectURL;
|
||||
});
|
||||
|
||||
it('decodes a BMP through canvas and returns PNG bytes', async () => {
|
||||
const file = makeFile('scan.bmp', 'image/bmp');
|
||||
const result = await normalizeImageToEmbeddable(file);
|
||||
expect(result.format).toBe('png');
|
||||
expect(Array.from(result.bytes)).toEqual([0x89, 0x50]);
|
||||
});
|
||||
|
||||
it('decodes a TIFF through canvas', async () => {
|
||||
const file = makeFile('page.tiff', 'image/tiff');
|
||||
const result = await normalizeImageToEmbeddable(file);
|
||||
expect(result.format).toBe('png');
|
||||
});
|
||||
|
||||
it('revokes the object URL after success', async () => {
|
||||
const file = makeFile('scan.bmp', 'image/bmp');
|
||||
await normalizeImageToEmbeddable(file);
|
||||
expect(revokeSpy).toHaveBeenCalledWith('blob:test');
|
||||
});
|
||||
|
||||
it('revokes the object URL even on Image decode error', async () => {
|
||||
globalThis.Image = class {
|
||||
naturalWidth = 0;
|
||||
naturalHeight = 0;
|
||||
onload: (() => void) | null = null;
|
||||
onerror: (() => void) | null = null;
|
||||
set src(_: string) {
|
||||
setTimeout(() => this.onerror?.(), 0);
|
||||
}
|
||||
} as unknown as typeof Image;
|
||||
|
||||
const file = makeFile('broken.gif', 'image/gif');
|
||||
await expect(normalizeImageToEmbeddable(file)).rejects.toThrow(
|
||||
/Failed to decode image/
|
||||
);
|
||||
expect(revokeSpy).toHaveBeenCalledWith('blob:test');
|
||||
});
|
||||
|
||||
it('rejects when canvas getContext returns null', async () => {
|
||||
document.createElement = ((tagName: string) => {
|
||||
if (tagName !== 'canvas') {
|
||||
return originalCreateElement(tagName);
|
||||
}
|
||||
return {
|
||||
width: 0,
|
||||
height: 0,
|
||||
getContext: vi.fn(() => null),
|
||||
toBlob: vi.fn(),
|
||||
} as unknown as HTMLCanvasElement;
|
||||
}) as typeof document.createElement;
|
||||
|
||||
const file = makeFile('scan.bmp', 'image/bmp');
|
||||
await expect(normalizeImageToEmbeddable(file)).rejects.toThrow(
|
||||
/Canvas context unavailable/
|
||||
);
|
||||
expect(revokeSpy).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rejects when canvas.toBlob returns null', async () => {
|
||||
document.createElement = ((tagName: string) => {
|
||||
if (tagName !== 'canvas') {
|
||||
return originalCreateElement(tagName);
|
||||
}
|
||||
return {
|
||||
width: 0,
|
||||
height: 0,
|
||||
getContext: vi.fn(() => ({ drawImage: vi.fn() })),
|
||||
toBlob: vi.fn((cb: (b: Blob | null) => void) => cb(null)),
|
||||
} as unknown as HTMLCanvasElement;
|
||||
}) as typeof document.createElement;
|
||||
|
||||
const file = makeFile('scan.bmp', 'image/bmp');
|
||||
await expect(normalizeImageToEmbeddable(file)).rejects.toThrow(
|
||||
/Canvas toBlob failed/
|
||||
);
|
||||
expect(revokeSpy).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('convertImagesToPdfFile', () => {
|
||||
beforeEach(() => {
|
||||
mockEmbedJpg.mockReset();
|
||||
mockEmbedPng.mockReset();
|
||||
mockDrawImage.mockReset();
|
||||
mockAddPage.mockReset();
|
||||
mockSave.mockReset();
|
||||
|
||||
mockEmbedJpg.mockResolvedValue({ width: 300, height: 200 });
|
||||
mockEmbedPng.mockResolvedValue({ width: 600, height: 400 });
|
||||
mockAddPage.mockReturnValue({ drawImage: mockDrawImage });
|
||||
mockSave.mockResolvedValue(new Uint8Array([0x25, 0x50, 0x44, 0x46]));
|
||||
});
|
||||
|
||||
it('embeds a single JPG as one page and names file after image', async () => {
|
||||
const file = makeFile('vacation.jpg', 'image/jpeg', [0xff, 0xd8]);
|
||||
const result = await convertImagesToPdfFile([file]);
|
||||
|
||||
expect(mockEmbedJpg).toHaveBeenCalledTimes(1);
|
||||
expect(mockEmbedPng).not.toHaveBeenCalled();
|
||||
expect(mockAddPage).toHaveBeenCalledWith([300, 200]);
|
||||
expect(mockDrawImage).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ width: 300, height: 200 }),
|
||||
{ x: 0, y: 0, width: 300, height: 200 }
|
||||
);
|
||||
expect(result.name).toBe('vacation.pdf');
|
||||
expect(result.type).toBe('application/pdf');
|
||||
});
|
||||
|
||||
it('embeds a single PNG and names file after image', async () => {
|
||||
const file = makeFile('scan.png', 'image/png', [0x89, 0x50]);
|
||||
const result = await convertImagesToPdfFile([file]);
|
||||
|
||||
expect(mockEmbedPng).toHaveBeenCalledTimes(1);
|
||||
expect(mockEmbedJpg).not.toHaveBeenCalled();
|
||||
expect(mockAddPage).toHaveBeenCalledWith([600, 400]);
|
||||
expect(result.name).toBe('scan.pdf');
|
||||
});
|
||||
|
||||
it('embeds multiple images and uses generic filename', async () => {
|
||||
const files = [
|
||||
makeFile('a.jpg', 'image/jpeg'),
|
||||
makeFile('b.png', 'image/png'),
|
||||
makeFile('c.jpg', 'image/jpeg'),
|
||||
];
|
||||
const result = await convertImagesToPdfFile(files);
|
||||
|
||||
expect(mockEmbedJpg).toHaveBeenCalledTimes(2);
|
||||
expect(mockEmbedPng).toHaveBeenCalledTimes(1);
|
||||
expect(mockAddPage).toHaveBeenCalledTimes(3);
|
||||
expect(result.name).toMatch(/^images-\d+\.pdf$/);
|
||||
});
|
||||
|
||||
it('produces a PDF File with correct MIME type', async () => {
|
||||
const file = makeFile('a.jpg', 'image/jpeg');
|
||||
const result = await convertImagesToPdfFile([file]);
|
||||
expect(result.type).toBe('application/pdf');
|
||||
expect(result).toBeInstanceOf(File);
|
||||
});
|
||||
|
||||
it('strips the original extension when generating single-file name', async () => {
|
||||
const file = makeFile('photo.JPEG', 'image/jpeg');
|
||||
const result = await convertImagesToPdfFile([file]);
|
||||
expect(result.name).toBe('photo.pdf');
|
||||
});
|
||||
|
||||
it('propagates errors from pdf-lib embed step', async () => {
|
||||
mockEmbedJpg.mockRejectedValueOnce(new Error('embed failed'));
|
||||
const file = makeFile('bad.jpg', 'image/jpeg');
|
||||
await expect(convertImagesToPdfFile([file])).rejects.toThrow(
|
||||
/embed failed/
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,73 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import DOMPurify, { type Config } from 'dompurify';
|
||||
|
||||
// Broken when applied after mermaid.render() — strips foreignObject labels.
|
||||
const BENTOPDF_SVG_ONLY_SANITIZE_OPTIONS: Config = {
|
||||
USE_PROFILES: { svg: true, svgFilters: true },
|
||||
};
|
||||
|
||||
/** Matches Mermaid 11 strict-mode output sanitization in mermaid.render(). */
|
||||
const MERMAID_STRICT_SVG_SANITIZE_OPTIONS: Config = {
|
||||
ADD_TAGS: ['foreignobject'],
|
||||
ADD_ATTR: ['dominant-baseline'],
|
||||
HTML_INTEGRATION_POINTS: { foreignobject: true },
|
||||
};
|
||||
|
||||
describe('Markdown to PDF — Mermaid SVG sanitization', () => {
|
||||
it('documents that SVG-only DOMPurify strips foreignObject labels (regression guard)', () => {
|
||||
const htmlLabelsSvg = `<svg xmlns="http://www.w3.org/2000/svg">
|
||||
<g class="node">
|
||||
<rect width="100" height="50"/>
|
||||
<foreignObject width="100" height="50">
|
||||
<div xmlns="http://www.w3.org/1999/xhtml" style="display:flex">
|
||||
<span>Start</span>
|
||||
</div>
|
||||
</foreignObject>
|
||||
</g>
|
||||
</svg>`;
|
||||
|
||||
const clean = DOMPurify.sanitize(
|
||||
htmlLabelsSvg,
|
||||
BENTOPDF_SVG_ONLY_SANITIZE_OPTIONS
|
||||
);
|
||||
|
||||
expect(clean).not.toContain('Start');
|
||||
expect(clean).not.toContain('foreignObject');
|
||||
});
|
||||
|
||||
it('preserves foreignObject labels with Mermaid strict sanitizer settings', () => {
|
||||
const htmlLabelsSvg = `<svg xmlns="http://www.w3.org/2000/svg">
|
||||
<foreignObject width="120" height="40">
|
||||
<div xmlns="http://www.w3.org/1999/xhtml" style="display:flex">
|
||||
<span class="nodeLabel">Start</span>
|
||||
</div>
|
||||
</foreignObject>
|
||||
</svg>`;
|
||||
|
||||
const clean = DOMPurify.sanitize(
|
||||
htmlLabelsSvg,
|
||||
MERMAID_STRICT_SVG_SANITIZE_OPTIONS
|
||||
);
|
||||
|
||||
expect(clean).toContain('Start');
|
||||
expect(clean).toContain('foreignObject');
|
||||
expect(clean).toContain('nodeLabel');
|
||||
});
|
||||
|
||||
it('preserves native SVG text labels under SVG-only profile', () => {
|
||||
const svgTextLabels = `<svg xmlns="http://www.w3.org/2000/svg">
|
||||
<g class="node">
|
||||
<rect width="100" height="50"/>
|
||||
<text x="10" y="20" fill="#333">Start</text>
|
||||
</g>
|
||||
</svg>`;
|
||||
|
||||
const clean = DOMPurify.sanitize(
|
||||
svgTextLabels,
|
||||
BENTOPDF_SVG_ONLY_SANITIZE_OPTIONS
|
||||
);
|
||||
|
||||
expect(clean).toContain('Start');
|
||||
expect(clean).toContain('<text');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,94 @@
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
|
||||
vi.mock('heic2any', () => ({
|
||||
default: vi.fn(),
|
||||
}));
|
||||
|
||||
import { partitionIncomingFiles } from '../js/utils/multi-tool-file-input';
|
||||
|
||||
function makeFile(name: string, type: string): File {
|
||||
return new File([new Uint8Array([1, 2, 3])], name, { type });
|
||||
}
|
||||
|
||||
describe('partitionIncomingFiles', () => {
|
||||
it('routes PDFs by MIME type', () => {
|
||||
const result = partitionIncomingFiles([
|
||||
makeFile('a.pdf', 'application/pdf'),
|
||||
makeFile('b.pdf', 'application/pdf'),
|
||||
]);
|
||||
expect(result.pdfFiles).toHaveLength(2);
|
||||
expect(result.imageFiles).toHaveLength(0);
|
||||
expect(result.skipped).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('routes PDFs by extension when MIME is missing', () => {
|
||||
const result = partitionIncomingFiles([makeFile('doc.pdf', '')]);
|
||||
expect(result.pdfFiles).toHaveLength(1);
|
||||
expect(result.imageFiles).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('routes PDFs with uppercase extension', () => {
|
||||
const result = partitionIncomingFiles([makeFile('SCAN.PDF', '')]);
|
||||
expect(result.pdfFiles).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('routes images by MIME type', () => {
|
||||
const result = partitionIncomingFiles([
|
||||
makeFile('a.png', 'image/png'),
|
||||
makeFile('b.jpg', 'image/jpeg'),
|
||||
makeFile('c.webp', 'image/webp'),
|
||||
]);
|
||||
expect(result.imageFiles).toHaveLength(3);
|
||||
expect(result.pdfFiles).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('routes images by extension when MIME is missing (HEIC, BMP, TIFF)', () => {
|
||||
const result = partitionIncomingFiles([
|
||||
makeFile('photo.heic', ''),
|
||||
makeFile('scan.bmp', ''),
|
||||
makeFile('page.tiff', ''),
|
||||
]);
|
||||
expect(result.imageFiles).toHaveLength(3);
|
||||
expect(result.skipped).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('splits a mixed batch into PDFs, images, and skipped', () => {
|
||||
const result = partitionIncomingFiles([
|
||||
makeFile('doc.pdf', 'application/pdf'),
|
||||
makeFile('photo.png', 'image/png'),
|
||||
makeFile('readme.txt', 'text/plain'),
|
||||
makeFile('archive.zip', 'application/zip'),
|
||||
]);
|
||||
expect(result.pdfFiles).toHaveLength(1);
|
||||
expect(result.imageFiles).toHaveLength(1);
|
||||
expect(result.skipped).toEqual(['readme.txt', 'archive.zip']);
|
||||
});
|
||||
|
||||
it('preserves original File order within each bucket', () => {
|
||||
const a = makeFile('a.pdf', 'application/pdf');
|
||||
const b = makeFile('b.png', 'image/png');
|
||||
const c = makeFile('c.pdf', 'application/pdf');
|
||||
const d = makeFile('d.jpg', 'image/jpeg');
|
||||
|
||||
const result = partitionIncomingFiles([a, b, c, d]);
|
||||
expect(result.pdfFiles).toEqual([a, c]);
|
||||
expect(result.imageFiles).toEqual([b, d]);
|
||||
});
|
||||
|
||||
it('returns empty buckets for empty input', () => {
|
||||
const result = partitionIncomingFiles([]);
|
||||
expect(result.pdfFiles).toHaveLength(0);
|
||||
expect(result.imageFiles).toHaveLength(0);
|
||||
expect(result.skipped).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('skips files with neither matching MIME nor extension', () => {
|
||||
const result = partitionIncomingFiles([
|
||||
makeFile('script.js', 'application/javascript'),
|
||||
makeFile('data.csv', 'text/csv'),
|
||||
]);
|
||||
expect(result.skipped).toHaveLength(2);
|
||||
expect(result.pdfFiles).toHaveLength(0);
|
||||
expect(result.imageFiles).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,364 @@
|
||||
import { describe, it, expect, beforeAll } from 'vitest';
|
||||
import * as fs from 'node:fs';
|
||||
import * as path from 'node:path';
|
||||
import createModule from '@neslinesli93/qpdf-wasm';
|
||||
import { PDFDocument } from 'pdf-lib';
|
||||
import type { QpdfInstanceExtended } from '@/types';
|
||||
import {
|
||||
parseRangeGroups,
|
||||
evenOddIndices,
|
||||
allPagesIndices,
|
||||
nTimesGroups,
|
||||
bookmarkSplitGroups,
|
||||
groupFilename,
|
||||
uniqueZipName,
|
||||
pagesToSpec,
|
||||
extractPagesWithQpdf,
|
||||
} from '@/js/utils/split-pdf-helpers';
|
||||
|
||||
const FIXTURES = path.resolve(__dirname, './fixtures');
|
||||
|
||||
describe('split-pdf-helpers (pure planning)', () => {
|
||||
describe('parseRangeGroups', () => {
|
||||
it('parses a single page into one group', () => {
|
||||
expect(parseRangeGroups('3', 10)).toEqual({
|
||||
groups: [[2]],
|
||||
indices: [2],
|
||||
});
|
||||
});
|
||||
|
||||
it('parses a hyphen range into one consecutive group', () => {
|
||||
expect(parseRangeGroups('2-5', 10)).toEqual({
|
||||
groups: [[1, 2, 3, 4]],
|
||||
indices: [1, 2, 3, 4],
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps disjoint ranges as separate groups, indices flattened in order', () => {
|
||||
expect(parseRangeGroups('1-5, 8, 11-13', 20)).toEqual({
|
||||
groups: [[0, 1, 2, 3, 4], [7], [10, 11, 12]],
|
||||
indices: [0, 1, 2, 3, 4, 7, 10, 11, 12],
|
||||
});
|
||||
});
|
||||
|
||||
it('preserves the user-entered order (does not sort)', () => {
|
||||
expect(parseRangeGroups('8, 1-2', 10)).toEqual({
|
||||
groups: [[7], [0, 1]],
|
||||
indices: [7, 0, 1],
|
||||
});
|
||||
});
|
||||
|
||||
it('skips whitespace and empty parts', () => {
|
||||
expect(parseRangeGroups(' 1 , , 3-4 ', 10)).toEqual({
|
||||
groups: [[0], [2, 3]],
|
||||
indices: [0, 2, 3],
|
||||
});
|
||||
});
|
||||
|
||||
it('skips out-of-bounds, reversed, and non-numeric ranges', () => {
|
||||
expect(parseRangeGroups('0-2, 9-5, abc, 3', 4)).toEqual({
|
||||
groups: [[2]],
|
||||
indices: [2],
|
||||
});
|
||||
});
|
||||
|
||||
it('skips a range whose end exceeds the page count', () => {
|
||||
expect(parseRangeGroups('1-100', 5)).toEqual({ groups: [], indices: [] });
|
||||
});
|
||||
|
||||
it('returns empty for an all-invalid input', () => {
|
||||
expect(parseRangeGroups('99, abc', 5)).toEqual({
|
||||
groups: [],
|
||||
indices: [],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('evenOddIndices', () => {
|
||||
it('selects even pages (1-based) as 0-based indices', () => {
|
||||
expect(evenOddIndices('even', 6)).toEqual([1, 3, 5]);
|
||||
});
|
||||
|
||||
it('selects odd pages', () => {
|
||||
expect(evenOddIndices('odd', 6)).toEqual([0, 2, 4]);
|
||||
});
|
||||
|
||||
it('handles odd total page counts', () => {
|
||||
expect(evenOddIndices('even', 5)).toEqual([1, 3]);
|
||||
expect(evenOddIndices('odd', 5)).toEqual([0, 2, 4]);
|
||||
});
|
||||
|
||||
it('handles a single-page document', () => {
|
||||
expect(evenOddIndices('odd', 1)).toEqual([0]);
|
||||
expect(evenOddIndices('even', 1)).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('allPagesIndices', () => {
|
||||
it('returns every page index', () => {
|
||||
expect(allPagesIndices(4)).toEqual([0, 1, 2, 3]);
|
||||
});
|
||||
|
||||
it('returns empty for zero pages', () => {
|
||||
expect(allPagesIndices(0)).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('nTimesGroups', () => {
|
||||
it('splits evenly when divisible', () => {
|
||||
expect(nTimesGroups(2, 6)).toEqual([
|
||||
[0, 1],
|
||||
[2, 3],
|
||||
[4, 5],
|
||||
]);
|
||||
});
|
||||
|
||||
it('puts the remainder in the final group', () => {
|
||||
expect(nTimesGroups(2, 5)).toEqual([[0, 1], [2, 3], [4]]);
|
||||
});
|
||||
|
||||
it('returns a single group when N >= total', () => {
|
||||
expect(nTimesGroups(10, 3)).toEqual([[0, 1, 2]]);
|
||||
});
|
||||
|
||||
it('produces one group per page when N is 1', () => {
|
||||
expect(nTimesGroups(1, 3)).toEqual([[0], [1], [2]]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('bookmarkSplitGroups', () => {
|
||||
it('builds consecutive ranges from boundary pages, first section starts at 0', () => {
|
||||
expect(bookmarkSplitGroups([2, 4], 8)).toEqual([
|
||||
[0, 1, 2, 3],
|
||||
[4, 5, 6, 7],
|
||||
]);
|
||||
});
|
||||
|
||||
it('sorts unsorted boundaries before grouping', () => {
|
||||
expect(bookmarkSplitGroups([4, 2], 8)).toEqual([
|
||||
[0, 1, 2, 3],
|
||||
[4, 5, 6, 7],
|
||||
]);
|
||||
});
|
||||
|
||||
it('treats a lone boundary as one whole-document group (preserves existing behavior)', () => {
|
||||
expect(bookmarkSplitGroups([3], 5)).toEqual([[0, 1, 2, 3, 4]]);
|
||||
});
|
||||
|
||||
it('splits at every boundary after the first', () => {
|
||||
expect(bookmarkSplitGroups([2, 4, 6], 8)).toEqual([
|
||||
[0, 1, 2, 3],
|
||||
[4, 5],
|
||||
[6, 7],
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('groupFilename', () => {
|
||||
it('names a single-page group page-N.pdf', () => {
|
||||
expect(groupFilename([4])).toBe('page-5.pdf');
|
||||
});
|
||||
|
||||
it('names a multi-page group pages-min-max.pdf', () => {
|
||||
expect(groupFilename([0, 1, 2, 3, 4])).toBe('pages-1-5.pdf');
|
||||
});
|
||||
});
|
||||
|
||||
describe('uniqueZipName', () => {
|
||||
it('returns the name unchanged on first use', () => {
|
||||
const used = new Map<string, number>();
|
||||
expect(uniqueZipName('page-1.pdf', used)).toBe('page-1.pdf');
|
||||
});
|
||||
|
||||
it('suffixes collisions instead of overwriting', () => {
|
||||
const used = new Map<string, number>();
|
||||
expect(uniqueZipName('page-1.pdf', used)).toBe('page-1.pdf');
|
||||
expect(uniqueZipName('page-1.pdf', used)).toBe('page-1-1.pdf');
|
||||
expect(uniqueZipName('page-1.pdf', used)).toBe('page-1-2.pdf');
|
||||
});
|
||||
});
|
||||
|
||||
describe('pagesToSpec', () => {
|
||||
it('converts 0-based indices to a 1-based comma-separated qpdf spec', () => {
|
||||
expect(pagesToSpec([0, 1, 7])).toBe('1,2,8');
|
||||
});
|
||||
|
||||
it('preserves order for non-ascending selections', () => {
|
||||
expect(pagesToSpec([7, 0, 1])).toBe('8,1,2');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('extractPagesWithQpdf (error handling, stubbed)', () => {
|
||||
function stubQpdf(
|
||||
exitCode: number,
|
||||
output: Uint8Array
|
||||
): QpdfInstanceExtended {
|
||||
return {
|
||||
callMain: () => exitCode,
|
||||
FS: {
|
||||
mkdir: () => {},
|
||||
mount: () => {},
|
||||
unmount: () => {},
|
||||
writeFile: () => {},
|
||||
readFile: () => output,
|
||||
unlink: () => {},
|
||||
analyzePath: () => ({ exists: true }),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
it('throws on a hard qpdf error (exit code 2)', () => {
|
||||
const qpdf = stubQpdf(2, new Uint8Array([1, 2, 3]));
|
||||
expect(() => extractPagesWithQpdf(qpdf, '/in.pdf', [0])).toThrow(
|
||||
/exit code 2/
|
||||
);
|
||||
});
|
||||
|
||||
it('throws when qpdf produces an empty file', () => {
|
||||
const qpdf = stubQpdf(0, new Uint8Array());
|
||||
expect(() => extractPagesWithQpdf(qpdf, '/in.pdf', [0])).toThrow(
|
||||
/empty PDF/
|
||||
);
|
||||
});
|
||||
|
||||
it('accepts exit code 3 (warnings) with valid output', () => {
|
||||
const out = new Uint8Array([37, 80, 68, 70]);
|
||||
const qpdf = stubQpdf(3, out);
|
||||
expect(extractPagesWithQpdf(qpdf, '/in.pdf', [0])).toBe(out);
|
||||
});
|
||||
});
|
||||
|
||||
describe('split modes end-to-end with real qpdf', () => {
|
||||
let qpdf: QpdfInstanceExtended;
|
||||
|
||||
async function makePdf(pageCount: number): Promise<Uint8Array> {
|
||||
const doc = await PDFDocument.create();
|
||||
for (let i = 0; i < pageCount; i++) doc.addPage([100 + i, 200]);
|
||||
return new Uint8Array(await doc.save());
|
||||
}
|
||||
|
||||
async function originalIndices(bytes: Uint8Array): Promise<number[]> {
|
||||
const doc = await PDFDocument.load(bytes);
|
||||
return doc.getPages().map((p) => Math.round(p.getWidth()) - 100);
|
||||
}
|
||||
|
||||
function extract(source: Uint8Array, indices: number[]): Uint8Array {
|
||||
qpdf.FS.writeFile('/in.pdf', source);
|
||||
try {
|
||||
return extractPagesWithQpdf(qpdf, '/in.pdf', indices);
|
||||
} finally {
|
||||
qpdf.FS.unlink('/in.pdf');
|
||||
}
|
||||
}
|
||||
|
||||
beforeAll(async () => {
|
||||
const wasmBinary = fs.readFileSync(
|
||||
path.resolve(
|
||||
process.cwd(),
|
||||
'node_modules/@neslinesli93/qpdf-wasm/dist/qpdf.wasm'
|
||||
)
|
||||
);
|
||||
qpdf = await (
|
||||
createModule as unknown as (o: object) => Promise<QpdfInstanceExtended>
|
||||
)({ wasmBinary, noInitialRun: true });
|
||||
});
|
||||
|
||||
it('range (combine): disjoint ranges -> one PDF with the right pages in order', async () => {
|
||||
const src = await makePdf(20);
|
||||
const { indices } = parseRangeGroups('1-5, 8, 11-13', 20);
|
||||
const out = extract(src, indices);
|
||||
expect(await originalIndices(out)).toEqual([0, 1, 2, 3, 4, 7, 10, 11, 12]);
|
||||
});
|
||||
|
||||
it('range (separate): each range group -> its own PDF', async () => {
|
||||
const src = await makePdf(20);
|
||||
const { groups } = parseRangeGroups('1-5, 8, 11-13', 20);
|
||||
const outputs = groups.map((g) => extract(src, g));
|
||||
const counts = await Promise.all(
|
||||
outputs.map(async (o) => (await PDFDocument.load(o)).getPageCount())
|
||||
);
|
||||
expect(counts).toEqual([5, 1, 3]);
|
||||
expect(groups.map(groupFilename)).toEqual([
|
||||
'pages-1-5.pdf',
|
||||
'page-8.pdf',
|
||||
'pages-11-13.pdf',
|
||||
]);
|
||||
});
|
||||
|
||||
it('even-odd: extracts the correct pages', async () => {
|
||||
const src = await makePdf(6);
|
||||
expect(
|
||||
await originalIndices(extract(src, evenOddIndices('even', 6)))
|
||||
).toEqual([1, 3, 5]);
|
||||
expect(
|
||||
await originalIndices(extract(src, evenOddIndices('odd', 6)))
|
||||
).toEqual([0, 2, 4]);
|
||||
});
|
||||
|
||||
it('all: one single-page PDF per page', async () => {
|
||||
const src = await makePdf(4);
|
||||
const groups = allPagesIndices(4).map((i) => [i]);
|
||||
const outputs = groups.map((g) => extract(src, g));
|
||||
const ids = await Promise.all(outputs.map((o) => originalIndices(o)));
|
||||
expect(ids).toEqual([[0], [1], [2], [3]]);
|
||||
});
|
||||
|
||||
it('n-times: chunks of N pages with remainder in the last file', async () => {
|
||||
const src = await makePdf(5);
|
||||
const groups = nTimesGroups(2, 5);
|
||||
const outputs = groups.map((g) => extract(src, g));
|
||||
const ids = await Promise.all(outputs.map((o) => originalIndices(o)));
|
||||
expect(ids).toEqual([[0, 1], [2, 3], [4]]);
|
||||
});
|
||||
|
||||
it('bookmarks: boundary pages -> consecutive section PDFs', async () => {
|
||||
const src = await makePdf(8);
|
||||
const groups = bookmarkSplitGroups([2, 4], 8);
|
||||
const outputs = groups.map((g) => extract(src, g));
|
||||
const ids = await Promise.all(outputs.map((o) => originalIndices(o)));
|
||||
expect(ids).toEqual([
|
||||
[0, 1, 2, 3],
|
||||
[4, 5, 6, 7],
|
||||
]);
|
||||
});
|
||||
|
||||
it('produces a single valid one-page PDF for a single index', async () => {
|
||||
const src = await makePdf(3);
|
||||
const out = extract(src, [1]);
|
||||
const doc = await PDFDocument.load(out);
|
||||
expect(doc.getPageCount()).toBe(1);
|
||||
expect(Math.round(doc.getPage(0).getWidth())).toBe(101);
|
||||
});
|
||||
|
||||
it('fixes resource bloat: a single-page extract is far smaller than pdf-lib copyPages', async () => {
|
||||
const src = new Uint8Array(
|
||||
fs.readFileSync(path.join(FIXTURES, 'shared-resources.pdf'))
|
||||
);
|
||||
|
||||
const srcDoc = await PDFDocument.load(src);
|
||||
const plDoc = await PDFDocument.create();
|
||||
(await plDoc.copyPages(srcDoc, [0])).forEach((p) => plDoc.addPage(p));
|
||||
const pdfLibSize = (await plDoc.save()).length;
|
||||
|
||||
const qpdfSize = extract(src, [0]).length;
|
||||
|
||||
expect(qpdfSize).toBeLessThan(pdfLibSize * 0.5);
|
||||
});
|
||||
|
||||
it('preserves the document outline that pdf-lib copyPages drops', async () => {
|
||||
const src = new Uint8Array(
|
||||
fs.readFileSync(path.join(FIXTURES, 'bookmarked.pdf'))
|
||||
);
|
||||
|
||||
const srcDoc = await PDFDocument.load(src);
|
||||
const plDoc = await PDFDocument.create();
|
||||
(await plDoc.copyPages(srcDoc, [0, 1, 2])).forEach((p) => plDoc.addPage(p));
|
||||
const pdfLibOut = new TextDecoder('latin1').decode(await plDoc.save());
|
||||
|
||||
const qpdfOut = new TextDecoder('latin1').decode(extract(src, [0, 1, 2]));
|
||||
|
||||
expect(pdfLibOut).not.toContain('/Outlines');
|
||||
expect(qpdfOut).toContain('/Outlines');
|
||||
});
|
||||
});
|
||||
@@ -34,6 +34,7 @@ const SUPPORTED_LANGUAGES = [
|
||||
'ko',
|
||||
'ja',
|
||||
'uk',
|
||||
'sk',
|
||||
] as const;
|
||||
const LANG_REGEX = new RegExp(
|
||||
`^/(${SUPPORTED_LANGUAGES.join('|')})(?:/(.*))?$`
|
||||
|
||||
Reference in New Issue
Block a user