mirror of
https://github.com/alam00000/bentopdf.git
synced 2026-11-05 20:02:47 +01:00
Compare commits
48
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
55b29c3097 | ||
|
|
90998edb25 | ||
|
|
84848ab902 | ||
|
|
2aaea50031 | ||
|
|
5fbf48601e | ||
|
|
4cec7bbe58 | ||
|
|
e505c70b2f | ||
|
|
841428f2e2 | ||
|
|
957b78fafd | ||
|
|
34f12655c1 | ||
|
|
74d7af283e | ||
|
|
5ef73edea2 | ||
|
|
9b099c6a6d | ||
|
|
146e3f07d4 | ||
|
|
e8563a1392 | ||
|
|
d66287a55b | ||
|
|
0695361469 | ||
|
|
88561b2fcb | ||
|
|
9686c38d7f | ||
|
|
5b7f1fc85f | ||
|
|
2c6d36037a | ||
|
|
98c65d2b45 | ||
|
|
1e7ef30fac | ||
|
|
0eb7dc7114 | ||
|
|
9e96abfbb1 | ||
|
|
9220507f63 | ||
|
|
0882bc9bc0 | ||
|
|
85d90c3382 | ||
|
|
88260c26ab | ||
|
|
582e5bf9cb | ||
|
|
6e47777c15 | ||
|
|
2e64e8e8d4 | ||
|
|
93d012c92a | ||
|
|
f5953d8c87 | ||
|
|
64153e698b | ||
|
|
518c18664d | ||
|
|
dbbad3f5d9 | ||
|
|
3e38e5b175 | ||
|
|
3fb779b231 | ||
|
|
988949dbf2 | ||
|
|
4f7f477898 | ||
|
|
c94d15f305 | ||
|
|
9283e89f19 | ||
|
|
a386fd26fc | ||
|
|
f7a1eef5ec | ||
|
|
7351cb2e32 | ||
|
|
c8391d5886 | ||
|
|
43d747b3c5 |
+1
-1
@@ -8,7 +8,7 @@ VITE_CORS_PROXY_SECRET=
|
|||||||
# WASM Module URLs
|
# WASM Module URLs
|
||||||
# Pre-configured defaults enable advanced PDF features out of the box.
|
# Pre-configured defaults enable advanced PDF features out of the box.
|
||||||
# For air-gapped / offline deployments, point these to your internal server (e.g., /wasm/pymupdf/).
|
# For air-gapped / offline deployments, point these to your internal server (e.g., /wasm/pymupdf/).
|
||||||
VITE_WASM_PYMUPDF_URL=https://cdn.jsdelivr.net/npm/@bentopdf/[email protected]4/
|
VITE_WASM_PYMUPDF_URL=https://cdn.jsdelivr.net/npm/@bentopdf/[email protected]6/
|
||||||
VITE_WASM_GS_URL=https://cdn.jsdelivr.net/npm/@bentopdf/gs-wasm/assets/
|
VITE_WASM_GS_URL=https://cdn.jsdelivr.net/npm/@bentopdf/gs-wasm/assets/
|
||||||
VITE_WASM_CPDF_URL=https://cdn.jsdelivr.net/npm/coherentpdf/dist/
|
VITE_WASM_CPDF_URL=https://cdn.jsdelivr.net/npm/coherentpdf/dist/
|
||||||
|
|
||||||
|
|||||||
@@ -75,7 +75,7 @@ jobs:
|
|||||||
uses: docker/setup-buildx-action@v3
|
uses: docker/setup-buildx-action@v3
|
||||||
|
|
||||||
- name: Login to GitHub Container Registry
|
- name: Login to GitHub Container Registry
|
||||||
uses: docker/login-action@v3
|
uses: docker/login-action@v4
|
||||||
with:
|
with:
|
||||||
registry: ghcr.io
|
registry: ghcr.io
|
||||||
username: ${{ github.repository_owner }}
|
username: ${{ github.repository_owner }}
|
||||||
@@ -144,7 +144,7 @@ jobs:
|
|||||||
uses: docker/setup-buildx-action@v3
|
uses: docker/setup-buildx-action@v3
|
||||||
|
|
||||||
- name: Login to GitHub Container Registry
|
- name: Login to GitHub Container Registry
|
||||||
uses: docker/login-action@v3
|
uses: docker/login-action@v4
|
||||||
with:
|
with:
|
||||||
registry: ghcr.io
|
registry: ghcr.io
|
||||||
username: ${{ github.repository_owner }}
|
username: ${{ github.repository_owner }}
|
||||||
@@ -206,7 +206,7 @@ jobs:
|
|||||||
suffix: "-simple"
|
suffix: "-simple"
|
||||||
steps:
|
steps:
|
||||||
- name: Login to GitHub Container Registry
|
- name: Login to GitHub Container Registry
|
||||||
uses: docker/login-action@v3
|
uses: docker/login-action@v4
|
||||||
with:
|
with:
|
||||||
registry: ghcr.io
|
registry: ghcr.io
|
||||||
username: ${{ github.repository_owner }}
|
username: ${{ github.repository_owner }}
|
||||||
@@ -270,14 +270,14 @@ jobs:
|
|||||||
suffix: "-simple"
|
suffix: "-simple"
|
||||||
steps:
|
steps:
|
||||||
- name: Login to GitHub Container Registry
|
- name: Login to GitHub Container Registry
|
||||||
uses: docker/login-action@v3
|
uses: docker/login-action@v4
|
||||||
with:
|
with:
|
||||||
registry: ghcr.io
|
registry: ghcr.io
|
||||||
username: ${{ github.repository_owner }}
|
username: ${{ github.repository_owner }}
|
||||||
password: ${{ secrets.GITHUB_TOKEN }}
|
password: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
||||||
- name: Login to DockerHub
|
- name: Login to DockerHub
|
||||||
uses: docker/login-action@v3
|
uses: docker/login-action@v4
|
||||||
with:
|
with:
|
||||||
username: ${{ secrets.DOCKER_USERNAME }}
|
username: ${{ secrets.DOCKER_USERNAME }}
|
||||||
password: ${{ secrets.DOCKER_TOKEN }}
|
password: ${{ secrets.DOCKER_TOKEN }}
|
||||||
|
|||||||
+5
-1
@@ -49,7 +49,11 @@ ENV VITE_FOOTER_TEXT=$VITE_FOOTER_TEXT
|
|||||||
|
|
||||||
ENV NODE_OPTIONS="--max-old-space-size=3072"
|
ENV NODE_OPTIONS="--max-old-space-size=3072"
|
||||||
|
|
||||||
RUN npm run build:with-docs
|
RUN --mount=type=secret,id=VITE_CORS_PROXY_URL \
|
||||||
|
--mount=type=secret,id=VITE_CORS_PROXY_SECRET \
|
||||||
|
VITE_CORS_PROXY_URL=$(cat /run/secrets/VITE_CORS_PROXY_URL 2>/dev/null || echo "") \
|
||||||
|
VITE_CORS_PROXY_SECRET=$(cat /run/secrets/VITE_CORS_PROXY_SECRET 2>/dev/null || echo "") \
|
||||||
|
npm run build:with-docs
|
||||||
|
|
||||||
# Production stage
|
# Production stage
|
||||||
FROM quay.io/nginx/nginx-unprivileged:stable-alpine-slim
|
FROM quay.io/nginx/nginx-unprivileged:stable-alpine-slim
|
||||||
|
|||||||
+5
-1
@@ -46,7 +46,11 @@ ENV VITE_FOOTER_TEXT=$VITE_FOOTER_TEXT
|
|||||||
|
|
||||||
ENV NODE_OPTIONS="--max-old-space-size=3072"
|
ENV NODE_OPTIONS="--max-old-space-size=3072"
|
||||||
|
|
||||||
RUN npm run build:with-docs
|
RUN --mount=type=secret,id=VITE_CORS_PROXY_URL \
|
||||||
|
--mount=type=secret,id=VITE_CORS_PROXY_SECRET \
|
||||||
|
VITE_CORS_PROXY_URL=$(cat /run/secrets/VITE_CORS_PROXY_URL 2>/dev/null || echo "") \
|
||||||
|
VITE_CORS_PROXY_SECRET=$(cat /run/secrets/VITE_CORS_PROXY_SECRET 2>/dev/null || echo "") \
|
||||||
|
npm run build:with-docs
|
||||||
|
|
||||||
# Production stage — uses standard nginx (starts as root, drops to PUID/PGID)
|
# Production stage — uses standard nginx (starts as root, drops to PUID/PGID)
|
||||||
FROM nginx:stable-alpine-slim
|
FROM nginx:stable-alpine-slim
|
||||||
|
|||||||
@@ -458,7 +458,7 @@ Advanced PDF features (PyMuPDF, Ghostscript, CoherentPDF) are pre-configured to
|
|||||||
The default URLs are set in `.env.production`:
|
The default URLs are set in `.env.production`:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
VITE_WASM_PYMUPDF_URL=https://cdn.jsdelivr.net/npm/@bentopdf/[email protected]4/
|
VITE_WASM_PYMUPDF_URL=https://cdn.jsdelivr.net/npm/@bentopdf/[email protected]6/
|
||||||
VITE_WASM_GS_URL=https://cdn.jsdelivr.net/npm/@bentopdf/gs-wasm/assets/
|
VITE_WASM_GS_URL=https://cdn.jsdelivr.net/npm/@bentopdf/gs-wasm/assets/
|
||||||
VITE_WASM_CPDF_URL=https://cdn.jsdelivr.net/npm/coherentpdf/dist/
|
VITE_WASM_CPDF_URL=https://cdn.jsdelivr.net/npm/coherentpdf/dist/
|
||||||
```
|
```
|
||||||
@@ -873,7 +873,10 @@ The **Digital Signature** tool uses a signing library that may need to fetch cer
|
|||||||
```
|
```
|
||||||
Or with Docker:
|
Or with Docker:
|
||||||
```bash
|
```bash
|
||||||
docker build --build-arg VITE_CORS_PROXY_URL="https://your-worker-url.workers.dev" -t your-bentopdf .
|
export VITE_CORS_PROXY_URL="https://your-worker-url.workers.dev"
|
||||||
|
DOCKER_BUILDKIT=1 docker build \
|
||||||
|
--secret id=VITE_CORS_PROXY_URL,env=VITE_CORS_PROXY_URL \
|
||||||
|
-t your-bentopdf .
|
||||||
```
|
```
|
||||||
|
|
||||||
#### Production Security Features
|
#### Production Security Features
|
||||||
@@ -927,6 +930,14 @@ npx wrangler secret put PROXY_SECRET
|
|||||||
|
|
||||||
# Set in build environment
|
# Set in build environment
|
||||||
VITE_CORS_PROXY_SECRET=your-secret npm run build
|
VITE_CORS_PROXY_SECRET=your-secret npm run build
|
||||||
|
|
||||||
|
# Or with Docker (optional; URL secret also shown for completeness)
|
||||||
|
export VITE_CORS_PROXY_URL="https://your-worker-url.workers.dev"
|
||||||
|
export VITE_CORS_PROXY_SECRET="your-secret"
|
||||||
|
DOCKER_BUILDKIT=1 docker build \
|
||||||
|
--secret id=VITE_CORS_PROXY_URL,env=VITE_CORS_PROXY_URL \
|
||||||
|
--secret id=VITE_CORS_PROXY_SECRET,env=VITE_CORS_PROXY_SECRET \
|
||||||
|
-t your-bentopdf .
|
||||||
```
|
```
|
||||||
|
|
||||||
### 📦 Version Management
|
### 📦 Version Management
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ npx wrangler secret put CPDF_SOURCE -c wasm-wrangler.toml
|
|||||||
|
|
||||||
**Recommended Source URLs:**
|
**Recommended Source URLs:**
|
||||||
|
|
||||||
- PYMUPDF_SOURCE: `https://cdn.jsdelivr.net/npm/@bentopdf/[email protected]4/`
|
- PYMUPDF_SOURCE: `https://cdn.jsdelivr.net/npm/@bentopdf/[email protected]6/`
|
||||||
- GS_SOURCE: `https://cdn.jsdelivr.net/npm/@bentopdf/gs-wasm/assets/`
|
- GS_SOURCE: `https://cdn.jsdelivr.net/npm/@bentopdf/gs-wasm/assets/`
|
||||||
- CPDF_SOURCE: `https://cdn.jsdelivr.net/npm/coherentpdf/dist/`
|
- CPDF_SOURCE: `https://cdn.jsdelivr.net/npm/coherentpdf/dist/`
|
||||||
|
|
||||||
|
|||||||
@@ -142,4 +142,13 @@ npx wrangler deploy
|
|||||||
VITE_CORS_PROXY_URL=https://your-worker.workers.dev npm run build
|
VITE_CORS_PROXY_URL=https://your-worker.workers.dev npm run build
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Or with Docker:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
export VITE_CORS_PROXY_URL="https://your-worker.workers.dev"
|
||||||
|
DOCKER_BUILDKIT=1 docker build \
|
||||||
|
--secret id=VITE_CORS_PROXY_URL,env=VITE_CORS_PROXY_URL \
|
||||||
|
-t your-bentopdf .
|
||||||
|
```
|
||||||
|
|
||||||
> **Note:** See [README](https://github.com/alam00000/bentopdf#digital-signature-cors-proxy-required) for HMAC signature setup.
|
> **Note:** See [README](https://github.com/alam00000/bentopdf#digital-signature-cors-proxy-required) for HMAC signature setup.
|
||||||
|
|||||||
@@ -64,8 +64,9 @@ If you're self-hosting BentoPDF, you'll need to deploy your own CORS proxy for d
|
|||||||
If using Docker:
|
If using Docker:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker build \
|
export VITE_CORS_PROXY_URL="https://your-worker.workers.dev"
|
||||||
--build-arg VITE_CORS_PROXY_URL="https://your-worker.workers.dev" \
|
DOCKER_BUILDKIT=1 docker build \
|
||||||
|
--secret id=VITE_CORS_PROXY_URL,env=VITE_CORS_PROXY_URL \
|
||||||
-t your-bentopdf .
|
-t your-bentopdf .
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
@@ -92,7 +92,7 @@ docker run -d -p 3000:8080 bentopdf:custom
|
|||||||
| ----------------------- | ------------------------------- | -------------------------------------------------------------- |
|
| ----------------------- | ------------------------------- | -------------------------------------------------------------- |
|
||||||
| `SIMPLE_MODE` | Build without LibreOffice tools | `false` |
|
| `SIMPLE_MODE` | Build without LibreOffice tools | `false` |
|
||||||
| `BASE_URL` | Deploy to subdirectory | `/` |
|
| `BASE_URL` | Deploy to subdirectory | `/` |
|
||||||
| `VITE_WASM_PYMUPDF_URL` | PyMuPDF WASM module URL | `https://cdn.jsdelivr.net/npm/@bentopdf/[email protected]4/` |
|
| `VITE_WASM_PYMUPDF_URL` | PyMuPDF WASM module URL | `https://cdn.jsdelivr.net/npm/@bentopdf/[email protected]6/` |
|
||||||
| `VITE_WASM_GS_URL` | Ghostscript WASM module URL | `https://cdn.jsdelivr.net/npm/@bentopdf/gs-wasm/assets/` |
|
| `VITE_WASM_GS_URL` | Ghostscript WASM module URL | `https://cdn.jsdelivr.net/npm/@bentopdf/gs-wasm/assets/` |
|
||||||
| `VITE_WASM_CPDF_URL` | CoherentPDF WASM module URL | `https://cdn.jsdelivr.net/npm/coherentpdf/dist/` |
|
| `VITE_WASM_CPDF_URL` | CoherentPDF WASM module URL | `https://cdn.jsdelivr.net/npm/coherentpdf/dist/` |
|
||||||
| `VITE_DEFAULT_LANGUAGE` | Default UI language | `en` |
|
| `VITE_DEFAULT_LANGUAGE` | Default UI language | `en` |
|
||||||
|
|||||||
@@ -167,7 +167,7 @@ As of v2.0.0, WASM modules are pre-configured to load from jsDelivr CDN via envi
|
|||||||
These are set in `.env.production` and baked into the build:
|
These are set in `.env.production` and baked into the build:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
VITE_WASM_PYMUPDF_URL=https://cdn.jsdelivr.net/npm/@bentopdf/[email protected]4/
|
VITE_WASM_PYMUPDF_URL=https://cdn.jsdelivr.net/npm/@bentopdf/[email protected]6/
|
||||||
VITE_WASM_GS_URL=https://cdn.jsdelivr.net/npm/@bentopdf/gs-wasm/assets/
|
VITE_WASM_GS_URL=https://cdn.jsdelivr.net/npm/@bentopdf/gs-wasm/assets/
|
||||||
VITE_WASM_CPDF_URL=https://cdn.jsdelivr.net/npm/coherentpdf/dist/
|
VITE_WASM_CPDF_URL=https://cdn.jsdelivr.net/npm/coherentpdf/dist/
|
||||||
```
|
```
|
||||||
|
|||||||
+15
-16
@@ -27,6 +27,21 @@ http {
|
|||||||
index index.html;
|
index index.html;
|
||||||
absolute_redirect off;
|
absolute_redirect off;
|
||||||
|
|
||||||
|
location ~ ^/(en|ar|be|da|de|es|fr|id|it|nl|pt|tr|vi|zh|zh-TW)(/.*)?$ {
|
||||||
|
try_files $uri $uri/ $uri.html /$1/index.html /index.html;
|
||||||
|
expires 5m;
|
||||||
|
add_header Cache-Control "public, must-revalidate";
|
||||||
|
add_header Cross-Origin-Embedder-Policy "require-corp" always;
|
||||||
|
add_header Cross-Origin-Opener-Policy "same-origin" always;
|
||||||
|
}
|
||||||
|
|
||||||
|
location ~ ^/(.+?)/(en|ar|be|da|de|es|fr|id|it|nl|pt|tr|vi|zh|zh-TW)(/.*)?$ {
|
||||||
|
try_files $uri $uri/ $uri.html /$1/$2/index.html /$1/index.html /index.html;
|
||||||
|
expires 5m;
|
||||||
|
add_header Cache-Control "public, must-revalidate";
|
||||||
|
add_header Cross-Origin-Embedder-Policy "require-corp" always;
|
||||||
|
add_header Cross-Origin-Opener-Policy "same-origin" always;
|
||||||
|
}
|
||||||
|
|
||||||
location ~* \.html$ {
|
location ~* \.html$ {
|
||||||
expires 1h;
|
expires 1h;
|
||||||
@@ -84,22 +99,6 @@ http {
|
|||||||
add_header Cache-Control "public, immutable";
|
add_header Cache-Control "public, immutable";
|
||||||
}
|
}
|
||||||
|
|
||||||
location ~ ^/(en|ar|be|da|de|es|fr|id|it|nl|pt|tr|vi|zh|zh-TW)(/.*)?$ {
|
|
||||||
try_files $uri $uri/ $uri.html /$1/index.html /index.html;
|
|
||||||
expires 5m;
|
|
||||||
add_header Cache-Control "public, must-revalidate";
|
|
||||||
add_header Cross-Origin-Embedder-Policy "require-corp" always;
|
|
||||||
add_header Cross-Origin-Opener-Policy "same-origin" always;
|
|
||||||
}
|
|
||||||
|
|
||||||
location ~ ^/(.+?)/(en|ar|be|da|de|es|fr|id|it|nl|pt|tr|vi|zh|zh-TW)(/.*)?$ {
|
|
||||||
try_files $uri $uri/ $uri.html /$1/$2/index.html /$1/index.html /index.html;
|
|
||||||
expires 5m;
|
|
||||||
add_header Cache-Control "public, must-revalidate";
|
|
||||||
add_header Cross-Origin-Embedder-Policy "require-corp" always;
|
|
||||||
add_header Cross-Origin-Opener-Policy "same-origin" always;
|
|
||||||
}
|
|
||||||
|
|
||||||
location / {
|
location / {
|
||||||
try_files $uri $uri/ $uri.html /index.html;
|
try_files $uri $uri/ $uri.html /index.html;
|
||||||
expires 5m;
|
expires 5m;
|
||||||
|
|||||||
Generated
+823
-1150
File diff suppressed because it is too large
Load Diff
+32
-31
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "bento-pdf",
|
"name": "bento-pdf",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "2.3.1",
|
"version": "2.4.0",
|
||||||
"license": "AGPL-3.0-only",
|
"license": "AGPL-3.0-only",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
@@ -37,32 +37,32 @@
|
|||||||
"prepare": "husky"
|
"prepare": "husky"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/js": "^9.39.2",
|
"@eslint/js": "^10.0.1",
|
||||||
"@testing-library/dom": "^10.4.1",
|
"@testing-library/dom": "^10.4.1",
|
||||||
"@types/blob-stream": "^0.1.33",
|
"@types/blob-stream": "^0.1.33",
|
||||||
"@types/html2canvas": "^1.0.0",
|
"@types/html2canvas": "^1.0.0",
|
||||||
"@types/pdfkit": "^0.17.3",
|
"@types/pdfkit": "^0.17.5",
|
||||||
"@types/sortablejs": "^1.15.8",
|
"@types/sortablejs": "^1.15.8",
|
||||||
"@types/utif": "^3.0.6",
|
"@types/utif": "^3.0.6",
|
||||||
"@vitejs/plugin-basic-ssl": "^2.1.4",
|
"@vitejs/plugin-basic-ssl": "^2.1.4",
|
||||||
"@vitest/coverage-v8": "^3.2.4",
|
"@vitest/coverage-v8": "^4.0.18",
|
||||||
"@vitest/ui": "^3.2.4",
|
"@vitest/ui": "^4.0.18",
|
||||||
"eslint": "^9.39.2",
|
"eslint": "^10.0.2",
|
||||||
"eslint-config-prettier": "^10.1.8",
|
"eslint-config-prettier": "^10.1.8",
|
||||||
"globals": "^17.0.0",
|
"globals": "^17.4.0",
|
||||||
"husky": "^9.1.7",
|
"husky": "^9.1.7",
|
||||||
"jsdom": "^27.0.1",
|
"jsdom": "^28.1.0",
|
||||||
"lint-staged": "^16.2.7",
|
"lint-staged": "^16.3.1",
|
||||||
"prettier": "^3.6.2",
|
"prettier": "^3.8.1",
|
||||||
"typescript": "~5.9.3",
|
"typescript": "~5.9.3",
|
||||||
"typescript-eslint": "^8.51.0",
|
"typescript-eslint": "^8.56.1",
|
||||||
"vite": "^7.1.11",
|
"vite": "^7.3.1",
|
||||||
"vite-plugin-compression": "^0.5.1",
|
"vite-plugin-compression": "^0.5.1",
|
||||||
"vite-plugin-handlebars": "^2.0.0",
|
"vite-plugin-handlebars": "^2.0.0",
|
||||||
"vite-plugin-node-polyfills": "^0.24.0",
|
"vite-plugin-node-polyfills": "^0.25.0",
|
||||||
"vitepress": "^1.6.4",
|
"vitepress": "^1.6.4",
|
||||||
"vitest": "^3.2.4",
|
"vitest": "^4.0.18",
|
||||||
"vue": "^3.5.26"
|
"vue": "^3.5.29"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@fontsource/cedarville-cursive": "^5.2.7",
|
"@fontsource/cedarville-cursive": "^5.2.7",
|
||||||
@@ -72,32 +72,33 @@
|
|||||||
"@fontsource/kalam": "^5.2.8",
|
"@fontsource/kalam": "^5.2.8",
|
||||||
"@fontsource/lato": "^5.2.7",
|
"@fontsource/lato": "^5.2.7",
|
||||||
"@fontsource/merriweather": "^5.2.11",
|
"@fontsource/merriweather": "^5.2.11",
|
||||||
"@kenjiuno/msgreader": "^1.27.1-alpha.1",
|
"@kenjiuno/msgreader": "^1.28.0",
|
||||||
"@matbee/libreoffice-converter": "^2.3.1",
|
"@matbee/libreoffice-converter": "^2.5.0",
|
||||||
"@neslinesli93/qpdf-wasm": "^0.3.0",
|
"@neslinesli93/qpdf-wasm": "^0.3.0",
|
||||||
"@pdf-lib/fontkit": "^1.1.1",
|
"@pdf-lib/fontkit": "^1.1.1",
|
||||||
"@phosphor-icons/web": "^2.1.2",
|
"@phosphor-icons/web": "^2.1.2",
|
||||||
"@retejs/lit-plugin": "^2.0.7",
|
"@retejs/lit-plugin": "^2.0.7",
|
||||||
"@tailwindcss/vite": "^4.1.15",
|
"@tailwindcss/vite": "^4.2.1",
|
||||||
"@types/markdown-it": "^14.1.2",
|
"@types/markdown-it": "^14.1.2",
|
||||||
"@types/node-forge": "^1.3.14",
|
"@types/node-forge": "^1.3.14",
|
||||||
"@types/papaparse": "^5.5.2",
|
"@types/papaparse": "^5.5.2",
|
||||||
"archiver": "^7.0.1",
|
"archiver": "^7.0.1",
|
||||||
"blob-stream": "^0.1.3",
|
"blob-stream": "^0.1.3",
|
||||||
"cropperjs": "^1.6.1",
|
"bwip-js": "^4.8.0",
|
||||||
|
"cropperjs": "^1.6.2",
|
||||||
"embedpdf-snippet": "file:vendor/embedpdf/embedpdf-snippet-2.3.0.tgz",
|
"embedpdf-snippet": "file:vendor/embedpdf/embedpdf-snippet-2.3.0.tgz",
|
||||||
"heic2any": "^0.0.4",
|
"heic2any": "^0.0.4",
|
||||||
"highlight.js": "^11.11.1",
|
"highlight.js": "^11.11.1",
|
||||||
"html2canvas": "^1.4.1",
|
"html2canvas": "^1.4.1",
|
||||||
"i18next": "^25.7.2",
|
"i18next": "^25.8.13",
|
||||||
"i18next-browser-languagedetector": "^8.2.0",
|
"i18next-browser-languagedetector": "^8.2.1",
|
||||||
"i18next-http-backend": "^3.0.2",
|
"i18next-http-backend": "^3.0.2",
|
||||||
"jspdf": "^4.1.0",
|
"jspdf": "^4.2.0",
|
||||||
"jspdf-autotable": "^5.0.2",
|
"jspdf-autotable": "^5.0.2",
|
||||||
"jszip": "^3.10.1",
|
"jszip": "^3.10.1",
|
||||||
"lit": "^3.3.2",
|
"lit": "^3.3.2",
|
||||||
"lucide": "^0.546.0",
|
"lucide": "^0.575.0",
|
||||||
"markdown-it": "^14.1.0",
|
"markdown-it": "^14.1.1",
|
||||||
"markdown-it-abbr": "^2.0.0",
|
"markdown-it-abbr": "^2.0.0",
|
||||||
"markdown-it-anchor": "^9.2.0",
|
"markdown-it-anchor": "^9.2.0",
|
||||||
"markdown-it-deflist": "^3.0.0",
|
"markdown-it-deflist": "^3.0.0",
|
||||||
@@ -109,25 +110,25 @@
|
|||||||
"markdown-it-sup": "^2.0.0",
|
"markdown-it-sup": "^2.0.0",
|
||||||
"markdown-it-task-lists": "^2.1.1",
|
"markdown-it-task-lists": "^2.1.1",
|
||||||
"markdown-it-toc-done-right": "^4.2.0",
|
"markdown-it-toc-done-right": "^4.2.0",
|
||||||
"mermaid": "^11.12.2",
|
"mermaid": "^11.12.3",
|
||||||
"node-forge": "^1.3.3",
|
"node-forge": "^1.3.3",
|
||||||
"papaparse": "^5.5.3",
|
"papaparse": "^5.5.3",
|
||||||
"pdf-lib": "^1.17.1",
|
"pdf-lib": "^1.17.1",
|
||||||
"pdfjs-dist": "^5.4.296",
|
"pdfjs-dist": "^5.4.624",
|
||||||
"pdfkit": "^0.17.2",
|
"pdfkit": "^0.17.2",
|
||||||
"postal-mime": "^2.7.1",
|
"postal-mime": "^2.7.3",
|
||||||
"rete": "^2.0.6",
|
"rete": "^2.0.6",
|
||||||
"rete-area-plugin": "^2.1.5",
|
"rete-area-plugin": "^2.1.5",
|
||||||
"rete-connection-plugin": "^2.0.5",
|
"rete-connection-plugin": "^2.0.5",
|
||||||
"rete-engine": "^2.1.1",
|
"rete-engine": "^2.1.1",
|
||||||
"rete-render-utils": "^2.0.3",
|
"rete-render-utils": "^2.0.3",
|
||||||
"sortablejs": "^1.15.6",
|
"sortablejs": "^1.15.7",
|
||||||
"tailwindcss": "^4.1.14",
|
"tailwindcss": "^4.1.14",
|
||||||
"terser": "^5.44.0",
|
"terser": "^5.46.0",
|
||||||
"tesseract.js": "^6.0.1",
|
"tesseract.js": "^7.0.0",
|
||||||
"tiff": "^7.1.2",
|
"tiff": "^7.1.2",
|
||||||
"utif": "^3.1.0",
|
"utif": "^3.1.0",
|
||||||
"vite-plugin-static-copy": "^3.1.4",
|
"vite-plugin-static-copy": "^3.2.0",
|
||||||
"xlsx": "https://cdn.sheetjs.com/xlsx-0.20.3/xlsx-0.20.3.tgz",
|
"xlsx": "https://cdn.sheetjs.com/xlsx-0.20.3/xlsx-0.20.3.tgz",
|
||||||
"zgapdfsigner": "^2.7.5"
|
"zgapdfsigner": "^2.7.5"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -92,7 +92,8 @@
|
|||||||
},
|
},
|
||||||
"addWatermark": {
|
"addWatermark": {
|
||||||
"name": "إضافة علامة مائية",
|
"name": "إضافة علامة مائية",
|
||||||
"subtitle": "ختم نص أو صورة على صفحات PDF الخاصة بك."
|
"subtitle": "ختم نص أو صورة على صفحات PDF الخاصة بك.",
|
||||||
|
"applyToAllPages": "تطبيق على جميع الصفحات"
|
||||||
},
|
},
|
||||||
"headerFooter": {
|
"headerFooter": {
|
||||||
"name": "رأس وتذييل",
|
"name": "رأس وتذييل",
|
||||||
@@ -162,7 +163,8 @@
|
|||||||
},
|
},
|
||||||
"removeBlankPages": {
|
"removeBlankPages": {
|
||||||
"name": "إزالة الصفحات الفارغة",
|
"name": "إزالة الصفحات الفارغة",
|
||||||
"subtitle": "اكتشاف وحذف الصفحات الفارغة تلقائيًا."
|
"subtitle": "اكتشاف وحذف الصفحات الفارغة تلقائيًا.",
|
||||||
|
"sensitivityHint": "أعلى = أكثر صرامة، فقط الصفحات الفارغة تمامًا. أقل = يسمح بصفحات تحتوي على بعض المحتوى."
|
||||||
},
|
},
|
||||||
"imageToPdf": {
|
"imageToPdf": {
|
||||||
"name": "صور إلى PDF",
|
"name": "صور إلى PDF",
|
||||||
|
|||||||
@@ -67,6 +67,7 @@
|
|||||||
"filesNeverLeave": "Вашы файлы ніколі не пакідаюць прыладу.",
|
"filesNeverLeave": "Вашы файлы ніколі не пакідаюць прыладу.",
|
||||||
"addMore": "Дадаць больш файлаў",
|
"addMore": "Дадаць больш файлаў",
|
||||||
"clearAll": "Ачысціць усё",
|
"clearAll": "Ачысціць усё",
|
||||||
|
"clearFiles": "Ачысціць файлы",
|
||||||
"hints": {
|
"hints": {
|
||||||
"singlePdf": "Адзін файл PDF",
|
"singlePdf": "Адзін файл PDF",
|
||||||
"pdfFile": "Файл PDF",
|
"pdfFile": "Файл PDF",
|
||||||
@@ -253,7 +254,8 @@
|
|||||||
"error": "Памылка",
|
"error": "Памылка",
|
||||||
"success": "Поспех",
|
"success": "Поспех",
|
||||||
"file": "Файл",
|
"file": "Файл",
|
||||||
"files": "Файлы"
|
"files": "Файлы",
|
||||||
|
"close": "Зачыніць"
|
||||||
},
|
},
|
||||||
"about": {
|
"about": {
|
||||||
"hero": {
|
"hero": {
|
||||||
|
|||||||
@@ -92,7 +92,8 @@
|
|||||||
},
|
},
|
||||||
"addWatermark": {
|
"addWatermark": {
|
||||||
"name": "Дадаць вадзяны знак",
|
"name": "Дадаць вадзяны знак",
|
||||||
"subtitle": "Накласці на старонкі PDF тэкст або відарыс."
|
"subtitle": "Накласці на старонкі PDF тэкст або відарыс.",
|
||||||
|
"applyToAllPages": "Прымяніць да ўсіх старонак"
|
||||||
},
|
},
|
||||||
"headerFooter": {
|
"headerFooter": {
|
||||||
"name": "Верхні і ніжні калантытул",
|
"name": "Верхні і ніжні калантытул",
|
||||||
@@ -162,7 +163,8 @@
|
|||||||
},
|
},
|
||||||
"removeBlankPages": {
|
"removeBlankPages": {
|
||||||
"name": "Выдаліць пустыя старонкі",
|
"name": "Выдаліць пустыя старонкі",
|
||||||
"subtitle": "Аўтаматычна выявіць і выдаліць пустыя старонкі."
|
"subtitle": "Аўтаматычна выявіць і выдаліць пустыя старонкі.",
|
||||||
|
"sensitivityHint": "Вышэй = больш строга, толькі цалкам пустыя старонкі. Ніжэй = дапускае старонкі з нейкім змесцівам."
|
||||||
},
|
},
|
||||||
"imageToPdf": {
|
"imageToPdf": {
|
||||||
"name": "Відарысы ў PDF",
|
"name": "Відарысы ў PDF",
|
||||||
|
|||||||
@@ -92,7 +92,8 @@
|
|||||||
},
|
},
|
||||||
"addWatermark": {
|
"addWatermark": {
|
||||||
"name": "Tilføj vandmærke",
|
"name": "Tilføj vandmærke",
|
||||||
"subtitle": "Placer tekst eller et billede oven på dine PDF-sider."
|
"subtitle": "Placer tekst eller et billede oven på dine PDF-sider.",
|
||||||
|
"applyToAllPages": "Anvend på alle sider"
|
||||||
},
|
},
|
||||||
"headerFooter": {
|
"headerFooter": {
|
||||||
"name": "Sidehoved og sidefod",
|
"name": "Sidehoved og sidefod",
|
||||||
@@ -162,7 +163,8 @@
|
|||||||
},
|
},
|
||||||
"removeBlankPages": {
|
"removeBlankPages": {
|
||||||
"name": "Fjern tomme sider",
|
"name": "Fjern tomme sider",
|
||||||
"subtitle": "Find og fjern automatisk tomme sider."
|
"subtitle": "Find og fjern automatisk tomme sider.",
|
||||||
|
"sensitivityHint": "Højere = strengere, kun helt tomme sider. Lavere = tillader sider med noget indhold."
|
||||||
},
|
},
|
||||||
"imageToPdf": {
|
"imageToPdf": {
|
||||||
"name": "Billeder til PDF",
|
"name": "Billeder til PDF",
|
||||||
|
|||||||
@@ -92,7 +92,8 @@
|
|||||||
},
|
},
|
||||||
"addWatermark": {
|
"addWatermark": {
|
||||||
"name": "Wasserzeichen hinzufügen",
|
"name": "Wasserzeichen hinzufügen",
|
||||||
"subtitle": "Text oder ein Bild über Ihre PDF-Seiten stempeln."
|
"subtitle": "Text oder ein Bild über Ihre PDF-Seiten stempeln.",
|
||||||
|
"applyToAllPages": "Auf alle Seiten anwenden"
|
||||||
},
|
},
|
||||||
"headerFooter": {
|
"headerFooter": {
|
||||||
"name": "Kopf- & Fußzeile",
|
"name": "Kopf- & Fußzeile",
|
||||||
@@ -162,7 +163,8 @@
|
|||||||
},
|
},
|
||||||
"removeBlankPages": {
|
"removeBlankPages": {
|
||||||
"name": "Leere Seiten entfernen",
|
"name": "Leere Seiten entfernen",
|
||||||
"subtitle": "Leere Seiten automatisch erkennen und löschen."
|
"subtitle": "Leere Seiten automatisch erkennen und löschen.",
|
||||||
|
"sensitivityHint": "Höher = strenger, nur rein leere Seiten. Niedriger = erlaubt Seiten mit etwas Inhalt."
|
||||||
},
|
},
|
||||||
"imageToPdf": {
|
"imageToPdf": {
|
||||||
"name": "Bilder zu PDF",
|
"name": "Bilder zu PDF",
|
||||||
|
|||||||
@@ -92,7 +92,8 @@
|
|||||||
},
|
},
|
||||||
"addWatermark": {
|
"addWatermark": {
|
||||||
"name": "Add Watermark",
|
"name": "Add Watermark",
|
||||||
"subtitle": "Stamp text or an image over your PDF pages."
|
"subtitle": "Stamp text or an image over your PDF pages.",
|
||||||
|
"applyToAllPages": "Apply to all pages"
|
||||||
},
|
},
|
||||||
"headerFooter": {
|
"headerFooter": {
|
||||||
"name": "Header & Footer",
|
"name": "Header & Footer",
|
||||||
@@ -162,7 +163,8 @@
|
|||||||
},
|
},
|
||||||
"removeBlankPages": {
|
"removeBlankPages": {
|
||||||
"name": "Remove Blank Pages",
|
"name": "Remove Blank Pages",
|
||||||
"subtitle": "Automatically detect and delete blank pages."
|
"subtitle": "Automatically detect and delete blank pages.",
|
||||||
|
"sensitivityHint": "Higher = stricter, only purely blank pages. Lower = allows pages with some content."
|
||||||
},
|
},
|
||||||
"imageToPdf": {
|
"imageToPdf": {
|
||||||
"name": "Images to PDF",
|
"name": "Images to PDF",
|
||||||
|
|||||||
@@ -92,7 +92,8 @@
|
|||||||
},
|
},
|
||||||
"addWatermark": {
|
"addWatermark": {
|
||||||
"name": "Agregar Marca de Agua",
|
"name": "Agregar Marca de Agua",
|
||||||
"subtitle": "Estampa texto o una imagen sobre tus páginas PDF."
|
"subtitle": "Estampa texto o una imagen sobre tus páginas PDF.",
|
||||||
|
"applyToAllPages": "Aplicar a todas las páginas"
|
||||||
},
|
},
|
||||||
"headerFooter": {
|
"headerFooter": {
|
||||||
"name": "Encabezado y Pie de Página",
|
"name": "Encabezado y Pie de Página",
|
||||||
@@ -162,7 +163,8 @@
|
|||||||
},
|
},
|
||||||
"removeBlankPages": {
|
"removeBlankPages": {
|
||||||
"name": "Eliminar Páginas en Blanco",
|
"name": "Eliminar Páginas en Blanco",
|
||||||
"subtitle": "Detecta y elimina automáticamente páginas en blanco."
|
"subtitle": "Detecta y elimina automáticamente páginas en blanco.",
|
||||||
|
"sensitivityHint": "Mayor = más estricto, solo páginas completamente en blanco. Menor = permite páginas con algo de contenido."
|
||||||
},
|
},
|
||||||
"imageToPdf": {
|
"imageToPdf": {
|
||||||
"name": "Imágenes a PDF",
|
"name": "Imágenes a PDF",
|
||||||
|
|||||||
@@ -92,7 +92,8 @@
|
|||||||
},
|
},
|
||||||
"addWatermark": {
|
"addWatermark": {
|
||||||
"name": "Ajouter un filigrane",
|
"name": "Ajouter un filigrane",
|
||||||
"subtitle": "Apposer un texte ou une image sur les pages du PDF."
|
"subtitle": "Apposer un texte ou une image sur les pages du PDF.",
|
||||||
|
"applyToAllPages": "Appliquer à toutes les pages"
|
||||||
},
|
},
|
||||||
"headerFooter": {
|
"headerFooter": {
|
||||||
"name": "En-tête et pied de page",
|
"name": "En-tête et pied de page",
|
||||||
@@ -162,7 +163,8 @@
|
|||||||
},
|
},
|
||||||
"removeBlankPages": {
|
"removeBlankPages": {
|
||||||
"name": "Supprimer les pages blanches",
|
"name": "Supprimer les pages blanches",
|
||||||
"subtitle": "Détecter et supprimer automatiquement les pages vides."
|
"subtitle": "Détecter et supprimer automatiquement les pages vides.",
|
||||||
|
"sensitivityHint": "Plus élevé = plus strict, uniquement les pages vierges. Plus bas = autorise les pages avec du contenu."
|
||||||
},
|
},
|
||||||
"imageToPdf": {
|
"imageToPdf": {
|
||||||
"name": "Images vers PDF",
|
"name": "Images vers PDF",
|
||||||
|
|||||||
@@ -92,7 +92,8 @@
|
|||||||
},
|
},
|
||||||
"addWatermark": {
|
"addWatermark": {
|
||||||
"name": "Tambah Watermark",
|
"name": "Tambah Watermark",
|
||||||
"subtitle": "Cap teks atau gambar di atas halaman PDF Anda."
|
"subtitle": "Cap teks atau gambar di atas halaman PDF Anda.",
|
||||||
|
"applyToAllPages": "Terapkan ke semua halaman"
|
||||||
},
|
},
|
||||||
"headerFooter": {
|
"headerFooter": {
|
||||||
"name": "Header & Footer",
|
"name": "Header & Footer",
|
||||||
@@ -162,7 +163,8 @@
|
|||||||
},
|
},
|
||||||
"removeBlankPages": {
|
"removeBlankPages": {
|
||||||
"name": "Hapus Halaman Kosong",
|
"name": "Hapus Halaman Kosong",
|
||||||
"subtitle": "Deteksi dan hapus halaman kosong secara otomatis."
|
"subtitle": "Deteksi dan hapus halaman kosong secara otomatis.",
|
||||||
|
"sensitivityHint": "Lebih tinggi = lebih ketat, hanya halaman yang benar-benar kosong. Lebih rendah = mengizinkan halaman dengan sedikit konten."
|
||||||
},
|
},
|
||||||
"imageToPdf": {
|
"imageToPdf": {
|
||||||
"name": "Gambar ke PDF",
|
"name": "Gambar ke PDF",
|
||||||
|
|||||||
@@ -92,7 +92,8 @@
|
|||||||
},
|
},
|
||||||
"addWatermark": {
|
"addWatermark": {
|
||||||
"name": "Aggiungi Filigrana",
|
"name": "Aggiungi Filigrana",
|
||||||
"subtitle": "Applica testo o un'immagine sulle pagine del tuo PDF."
|
"subtitle": "Applica testo o un'immagine sulle pagine del tuo PDF.",
|
||||||
|
"applyToAllPages": "Applica a tutte le pagine"
|
||||||
},
|
},
|
||||||
"headerFooter": {
|
"headerFooter": {
|
||||||
"name": "Intestazione e Piè di Pagina",
|
"name": "Intestazione e Piè di Pagina",
|
||||||
@@ -162,7 +163,8 @@
|
|||||||
},
|
},
|
||||||
"removeBlankPages": {
|
"removeBlankPages": {
|
||||||
"name": "Rimuovi Pagine Vuote",
|
"name": "Rimuovi Pagine Vuote",
|
||||||
"subtitle": "Rileva e elimina automaticamente le pagine vuote."
|
"subtitle": "Rileva e elimina automaticamente le pagine vuote.",
|
||||||
|
"sensitivityHint": "Più alto = più rigoroso, solo pagine completamente vuote. Più basso = consente pagine con qualche contenuto."
|
||||||
},
|
},
|
||||||
"imageToPdf": {
|
"imageToPdf": {
|
||||||
"name": "Immagini in PDF",
|
"name": "Immagini in PDF",
|
||||||
|
|||||||
@@ -56,7 +56,7 @@
|
|||||||
"title": "Aan de slag met",
|
"title": "Aan de slag met",
|
||||||
"toolsLabel": "Tools",
|
"toolsLabel": "Tools",
|
||||||
"subtitle": "Klik een tool om de bestandslader te openen",
|
"subtitle": "Klik een tool om de bestandslader te openen",
|
||||||
"searchPlaceholder": "Zoek een tool (bijv., 'splitsen', 'organiseren'...)",
|
"searchPlaceholder": "Zoek een tool (bijv. 'splitsen', 'organiseren', ...)",
|
||||||
"backToTools": "Terug naar Tools",
|
"backToTools": "Terug naar Tools",
|
||||||
"firstLoadNotice": "De eerste keer duurt het even om onze conversiemachine te laden. Daarna gaat alles meteen."
|
"firstLoadNotice": "De eerste keer duurt het even om onze conversiemachine te laden. Daarna gaat alles meteen."
|
||||||
},
|
},
|
||||||
@@ -95,6 +95,15 @@
|
|||||||
"webpImages": "WebP-afbeeldingen"
|
"webpImages": "WebP-afbeeldingen"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"howItWorks": {
|
||||||
|
"title": "Hoe het werkt",
|
||||||
|
"step1": "Klik of sleep uw bestand hierheen",
|
||||||
|
"step2": "Klik op de verwerkingsknop",
|
||||||
|
"step3": "Sla uw verwerkte bestand direct op"
|
||||||
|
},
|
||||||
|
"relatedTools": {
|
||||||
|
"title": "Gerelateerde PDF-tools"
|
||||||
|
},
|
||||||
"loader": {
|
"loader": {
|
||||||
"processing": "Verwerken..."
|
"processing": "Verwerken..."
|
||||||
},
|
},
|
||||||
@@ -166,6 +175,7 @@
|
|||||||
"faq": {
|
"faq": {
|
||||||
"title": "Veelgestelde",
|
"title": "Veelgestelde",
|
||||||
"questions": "Vragen",
|
"questions": "Vragen",
|
||||||
|
"sectionTitle": "Veelgestelde vragen",
|
||||||
"isFree": {
|
"isFree": {
|
||||||
"question": "Is BentoPDF echt gratis?",
|
"question": "Is BentoPDF echt gratis?",
|
||||||
"answer": "Ja, absoluut. Alle tools op BentoPDF zijn 100% gratis te gebruiken, zonder bestandslimieten, zonder aanmeldingen en zonder watermerken. Wij vinden dat iedereen toegang verdient tot eenvoudige, krachtige PDF-tools zonder betaalmuur."
|
"answer": "Ja, absoluut. Alle tools op BentoPDF zijn 100% gratis te gebruiken, zonder bestandslimieten, zonder aanmeldingen en zonder watermerken. Wij vinden dat iedereen toegang verdient tot eenvoudige, krachtige PDF-tools zonder betaalmuur."
|
||||||
@@ -197,8 +207,7 @@
|
|||||||
"analytics": {
|
"analytics": {
|
||||||
"question": "Gebruikt BentoPDF cookies of analytics om mij te volgen?",
|
"question": "Gebruikt BentoPDF cookies of analytics om mij te volgen?",
|
||||||
"answer": "We geven om je privacy. BentoPDF houdt geen persoonlijke gegevens bij. We gebruiken Simple Analytics alleen om anonieme bezoekersaantallen te zien. Dit betekent dat we kunnen weten hoeveel mensen onze site bezoeken, maar we weten nooit wie jij bent. Simple Analytics is volledig AVG-conform en respecteert je privacy."
|
"answer": "We geven om je privacy. BentoPDF houdt geen persoonlijke gegevens bij. We gebruiken Simple Analytics alleen om anonieme bezoekersaantallen te zien. Dit betekent dat we kunnen weten hoeveel mensen onze site bezoeken, maar we weten nooit wie jij bent. Simple Analytics is volledig AVG-conform en respecteert je privacy."
|
||||||
},
|
}
|
||||||
"sectionTitle": "Veelgestelde vragen"
|
|
||||||
},
|
},
|
||||||
"testimonials": {
|
"testimonials": {
|
||||||
"title": "Wat onze",
|
"title": "Wat onze",
|
||||||
@@ -349,17 +358,9 @@
|
|||||||
"error": "Fout",
|
"error": "Fout",
|
||||||
"failedToLoad": "Laden is mislukt"
|
"failedToLoad": "Laden is mislukt"
|
||||||
},
|
},
|
||||||
"howItWorks": {
|
|
||||||
"title": "Hoe het werkt",
|
|
||||||
"step1": "Klik of sleep uw bestand hierheen",
|
|
||||||
"step2": "Klik op de verwerkingsknop",
|
|
||||||
"step3": "Sla uw verwerkte bestand direct op"
|
|
||||||
},
|
|
||||||
"relatedTools": {
|
|
||||||
"title": "Gerelateerde PDF-tools"
|
|
||||||
},
|
|
||||||
"simpleMode": {
|
"simpleMode": {
|
||||||
"title": "PDF-tools",
|
"title": "PDF-tools",
|
||||||
"subtitle": "Selecteer een tool om te beginnen"
|
"subtitle": "Selecteer een tool om te beginnen"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -92,7 +92,8 @@
|
|||||||
},
|
},
|
||||||
"addWatermark": {
|
"addWatermark": {
|
||||||
"name": "Watermerk toevoegen",
|
"name": "Watermerk toevoegen",
|
||||||
"subtitle": "Tekst of een afbeelding over de pagina's van je PDF stempelen."
|
"subtitle": "Tekst of een afbeelding over de pagina's van je PDF stempelen.",
|
||||||
|
"applyToAllPages": "Op alle pagina's toepassen"
|
||||||
},
|
},
|
||||||
"headerFooter": {
|
"headerFooter": {
|
||||||
"name": "Koptekst & Voettekst",
|
"name": "Koptekst & Voettekst",
|
||||||
@@ -162,7 +163,8 @@
|
|||||||
},
|
},
|
||||||
"removeBlankPages": {
|
"removeBlankPages": {
|
||||||
"name": "Blanco pagina's Verwijderen",
|
"name": "Blanco pagina's Verwijderen",
|
||||||
"subtitle": "Automatisch blanco pagina's detecteren en verwijderen."
|
"subtitle": "Automatisch blanco pagina's detecteren en verwijderen.",
|
||||||
|
"sensitivityHint": "Hoger = strenger, alleen volledig lege pagina's. Lager = staat pagina's met enige inhoud toe."
|
||||||
},
|
},
|
||||||
"imageToPdf": {
|
"imageToPdf": {
|
||||||
"name": "Afbeelding naar PDF",
|
"name": "Afbeelding naar PDF",
|
||||||
@@ -617,7 +619,7 @@
|
|||||||
"workflowCompleted": "Werkproces voltooid",
|
"workflowCompleted": "Werkproces voltooid",
|
||||||
"errorDuringExecution": "Fout tijdens uitvoering",
|
"errorDuringExecution": "Fout tijdens uitvoering",
|
||||||
"addNodeError": "Voeg minimaal één knooppunt toe om het werkproces uit te voeren.",
|
"addNodeError": "Voeg minimaal één knooppunt toe om het werkproces uit te voeren.",
|
||||||
"needInputOutput": "Om te kunnen functioneren heeft een werkproces heeft tenminste een invoerknoopunt en een uitvoer-knooppunt nodig.",
|
"needInputOutput": "Het werkproces heeft tenminste een invoerknoopunt en een uitvoer-knooppunt nodig.",
|
||||||
"enterName": "Voer een naam in.",
|
"enterName": "Voer een naam in.",
|
||||||
"templateExists": "Er bestaat al een sjabloon met deze naam.",
|
"templateExists": "Er bestaat al een sjabloon met deze naam.",
|
||||||
"templateSaved": "Sjabloon \"{{name}}\" opgeslagen.",
|
"templateSaved": "Sjabloon \"{{name}}\" opgeslagen.",
|
||||||
|
|||||||
+155
-131
@@ -22,7 +22,29 @@
|
|||||||
},
|
},
|
||||||
"compressPdf": {
|
"compressPdf": {
|
||||||
"name": "Comprimir PDF",
|
"name": "Comprimir PDF",
|
||||||
"subtitle": "Reduza o tamanho do arquivo do seu PDF."
|
"subtitle": "Reduza o tamanho do arquivo do seu PDF.",
|
||||||
|
"algorithmLabel": "Algoritmo de Compressão",
|
||||||
|
"condense": "Condense (Recomendado)",
|
||||||
|
"photon": "Photon (Para PDFs com Muitas Fotos)",
|
||||||
|
"condenseInfo": "Condense usa compressão avançada: remove dados desnecessários, otimiza imagens, subconjunta fontes. Ideal para a maioria dos PDFs.",
|
||||||
|
"photonInfo": "Photon converte páginas em imagens. Use para PDFs com muitas fotos/digitalizados.",
|
||||||
|
"photonWarning": "Aviso: O texto ficará não selecionável e os links deixarão de funcionar.",
|
||||||
|
"levelLabel": "Nível de Compressão",
|
||||||
|
"light": "Leve (Preservar Qualidade)",
|
||||||
|
"balanced": "Equilibrado (Recomendado)",
|
||||||
|
"aggressive": "Agressivo (Arquivos Menores)",
|
||||||
|
"extreme": "Extremo (Compressão Máxima)",
|
||||||
|
"grayscale": "Converter para Escala de Cinza",
|
||||||
|
"grayscaleHint": "Reduz o tamanho do arquivo removendo informações de cor",
|
||||||
|
"customSettings": "Configurações Personalizadas",
|
||||||
|
"customSettingsHint": "Ajuste fino dos parâmetros de compressão:",
|
||||||
|
"outputQuality": "Qualidade de Saída",
|
||||||
|
"resizeImagesTo": "Redimensionar Imagens Para",
|
||||||
|
"onlyProcessAbove": "Processar Apenas Acima de",
|
||||||
|
"removeMetadata": "Remover metadados",
|
||||||
|
"subsetFonts": "Subconjunto de fontes (remover glifos não utilizados)",
|
||||||
|
"removeThumbnails": "Remover miniaturas incorporadas",
|
||||||
|
"compressButton": "Comprimir PDF"
|
||||||
},
|
},
|
||||||
"pdfEditor": {
|
"pdfEditor": {
|
||||||
"name": "Editor de PDF",
|
"name": "Editor de PDF",
|
||||||
@@ -70,7 +92,8 @@
|
|||||||
},
|
},
|
||||||
"addWatermark": {
|
"addWatermark": {
|
||||||
"name": "Adicionar Marca d'Água",
|
"name": "Adicionar Marca d'Água",
|
||||||
"subtitle": "Carimbe texto ou uma imagem sobre as páginas do seu PDF."
|
"subtitle": "Carimbe texto ou uma imagem sobre as páginas do seu PDF.",
|
||||||
|
"applyToAllPages": "Aplicar a todas as páginas"
|
||||||
},
|
},
|
||||||
"headerFooter": {
|
"headerFooter": {
|
||||||
"name": "Cabeçalho e Rodapé",
|
"name": "Cabeçalho e Rodapé",
|
||||||
@@ -140,7 +163,8 @@
|
|||||||
},
|
},
|
||||||
"removeBlankPages": {
|
"removeBlankPages": {
|
||||||
"name": "Remover Páginas em Branco",
|
"name": "Remover Páginas em Branco",
|
||||||
"subtitle": "Detecte e exclua automaticamente páginas em branco."
|
"subtitle": "Detecte e exclua automaticamente páginas em branco.",
|
||||||
|
"sensitivityHint": "Maior = mais rigoroso, apenas páginas totalmente em branco. Menor = permite páginas com algum conteúdo."
|
||||||
},
|
},
|
||||||
"imageToPdf": {
|
"imageToPdf": {
|
||||||
"name": "Imagem para PDF",
|
"name": "Imagem para PDF",
|
||||||
@@ -207,7 +231,7 @@
|
|||||||
"subtitle": "Converta arquivos PDF para o formato JSON."
|
"subtitle": "Converta arquivos PDF para o formato JSON."
|
||||||
},
|
},
|
||||||
"ocrPdf": {
|
"ocrPdf": {
|
||||||
"name": "OCR PDF",
|
"name": "OCR para PDF",
|
||||||
"subtitle": "Torne um PDF pesquisável e copiável (reconhecimento de texto)."
|
"subtitle": "Torne um PDF pesquisável e copiável (reconhecimento de texto)."
|
||||||
},
|
},
|
||||||
"alternateMix": {
|
"alternateMix": {
|
||||||
@@ -329,192 +353,192 @@
|
|||||||
"subtitle": "Endireite automaticamente páginas digitalizadas inclinadas usando OpenCV."
|
"subtitle": "Endireite automaticamente páginas digitalizadas inclinadas usando OpenCV."
|
||||||
},
|
},
|
||||||
"rotateCustom": {
|
"rotateCustom": {
|
||||||
"name": "Rotate by Custom Degrees",
|
"name": "Rotacionar em Graus Customizáveis",
|
||||||
"subtitle": "Rotate pages by any custom angle."
|
"subtitle": "Rotaciona páginas em qualquer ângulo customizado."
|
||||||
},
|
},
|
||||||
"odtToPdf": {
|
"odtToPdf": {
|
||||||
"name": "ODT to PDF",
|
"name": "ODT para PDF",
|
||||||
"subtitle": "Convert OpenDocument Text files to PDF format. Supports multiple files.",
|
"subtitle": "Converte arquivos em formato OpenDocument Text para PDF. Suporta multiplos arquivos.",
|
||||||
"acceptedFormats": "ODT files",
|
"acceptedFormats": "Arquivos ODT",
|
||||||
"convertButton": "Convert to PDF"
|
"convertButton": "Converter para PDF"
|
||||||
},
|
},
|
||||||
"csvToPdf": {
|
"csvToPdf": {
|
||||||
"name": "CSV to PDF",
|
"name": "CSV para PDF",
|
||||||
"subtitle": "Convert CSV spreadsheet files to PDF format. Supports multiple files.",
|
"subtitle": "Converte arquivos de planilhas CSV para PDF. Suporta múltiplos arquivos.",
|
||||||
"acceptedFormats": "CSV files",
|
"acceptedFormats": "Arquivos CSV",
|
||||||
"convertButton": "Convert to PDF"
|
"convertButton": "Converter para PDF"
|
||||||
},
|
},
|
||||||
"rtfToPdf": {
|
"rtfToPdf": {
|
||||||
"name": "RTF to PDF",
|
"name": "RTF para PDF",
|
||||||
"subtitle": "Convert Rich Text Format documents to PDF. Supports multiple files.",
|
"subtitle": "Converte documentos Rich Text Format para PDF. Suporta múltiplos arquivos.",
|
||||||
"acceptedFormats": "RTF files",
|
"acceptedFormats": "Arquivos RTF",
|
||||||
"convertButton": "Convert to PDF"
|
"convertButton": "Converter para PDF"
|
||||||
},
|
},
|
||||||
"wordToPdf": {
|
"wordToPdf": {
|
||||||
"name": "Word to PDF",
|
"name": "Word para PDF",
|
||||||
"subtitle": "Convert Word documents (DOCX, DOC, ODT, RTF) to PDF format. Supports multiple files.",
|
"subtitle": "Converte documentos Word documents (DOCX, DOC, ODT, RTF) para PDF. Suporta múltiplos arquivos.",
|
||||||
"acceptedFormats": "DOCX, DOC, ODT, RTF files",
|
"acceptedFormats": "Arquivos DOCX, DOC, ODT, RTF",
|
||||||
"convertButton": "Convert to PDF"
|
"convertButton": "Converter para PDF"
|
||||||
},
|
},
|
||||||
"excelToPdf": {
|
"excelToPdf": {
|
||||||
"name": "Excel to PDF",
|
"name": "Excel para PDF",
|
||||||
"subtitle": "Convert Excel spreadsheets (XLSX, XLS, ODS, CSV) to PDF format. Supports multiple files.",
|
"subtitle": "Converte planilhas Excel (XLSX, XLS, ODS, CSV) para PDF. Suporta múltiplos arquivos.",
|
||||||
"acceptedFormats": "XLSX, XLS, ODS, CSV files",
|
"acceptedFormats": "Arquivos XLSX, XLS, ODS, CSV",
|
||||||
"convertButton": "Convert to PDF"
|
"convertButton": "Converter para PDF"
|
||||||
},
|
},
|
||||||
"powerpointToPdf": {
|
"powerpointToPdf": {
|
||||||
"name": "PowerPoint to PDF",
|
"name": "PowerPoint para PDF",
|
||||||
"subtitle": "Convert PowerPoint presentations (PPTX, PPT, ODP) to PDF format. Supports multiple files.",
|
"subtitle": "Converte apresentações PowerPoint (PPTX, PPT, ODP) para PDF. Suporta múltiplos arquivos.",
|
||||||
"acceptedFormats": "PPTX, PPT, ODP files",
|
"acceptedFormats": "Arquivos PPTX, PPT, ODP",
|
||||||
"convertButton": "Convert to PDF"
|
"convertButton": "Converter para PDF"
|
||||||
},
|
},
|
||||||
"markdownToPdf": {
|
"markdownToPdf": {
|
||||||
"name": "Markdown to PDF",
|
"name": "Markdown para PDF",
|
||||||
"subtitle": "Write or paste Markdown and export it as a beautifully formatted PDF.",
|
"subtitle": "Escreva ou cole Markdown e exporte como um belo formato PDF.",
|
||||||
"paneMarkdown": "Markdown",
|
"paneMarkdown": "Markdown",
|
||||||
"panePreview": "Preview",
|
"panePreview": "Pré-visualizar",
|
||||||
"btnUpload": "Upload",
|
"btnUpload": "Upload",
|
||||||
"btnSyncScroll": "Sync Scroll",
|
"btnSyncScroll": "Sincronizar Scroll",
|
||||||
"btnSettings": "Settings",
|
"btnSettings": "Configurações",
|
||||||
"btnExportPdf": "Export PDF",
|
"btnExportPdf": "Exportar PDF",
|
||||||
"settingsTitle": "Markdown Settings",
|
"settingsTitle": "Configurações Markdown",
|
||||||
"settingsPreset": "Preset",
|
"settingsPreset": "Predefinição",
|
||||||
"presetDefault": "Default (GFM-like)",
|
"presetDefault": "Padrão (tipo GFM)",
|
||||||
"presetCommonmark": "CommonMark (strict)",
|
"presetCommonmark": "CommonMark (extrito)",
|
||||||
"presetZero": "Minimal (no features)",
|
"presetZero": "Mínimo (sem features)",
|
||||||
"settingsOptions": "Markdown Options",
|
"settingsOptions": "Opções de Markdown",
|
||||||
"optAllowHtml": "Allow HTML tags",
|
"optAllowHtml": "Permitir tags HTML",
|
||||||
"optBreaks": "Convert newlines to <br>",
|
"optBreaks": "Converter novas linhas em <br>",
|
||||||
"optLinkify": "Auto-convert URLs to links",
|
"optLinkify": "Auto-converter URLs para links",
|
||||||
"optTypographer": "Typographer (smart quotes, etc.)"
|
"optTypographer": "Tipógrafo (aspas inteligentes, etc.)"
|
||||||
},
|
},
|
||||||
"pdfBooklet": {
|
"pdfBooklet": {
|
||||||
"name": "PDF Booklet",
|
"name": "PDF Booklet",
|
||||||
"subtitle": "Rearrange pages for double-sided booklet printing. Fold and staple to create a booklet.",
|
"subtitle": "Reordena páginas para impressão de livreto frente e verso. Dobre e grampeie para criar um livreto.",
|
||||||
"howItWorks": "How it works:",
|
"howItWorks": "Como funciona:",
|
||||||
"step1": "Upload a PDF file.",
|
"step1": "Faça o upload de um arquivo PDF.",
|
||||||
"step2": "Pages will be rearranged in booklet order.",
|
"step2": "As páginas serão reordenadas na ordem de livretos.",
|
||||||
"step3": "Print double-sided, flip on short edge, fold and staple.",
|
"step3": "Imprima frente e verso, vire pelo topo, dobre e grampeie.",
|
||||||
"paperSize": "Paper Size",
|
"paperSize": "Tamanho do Papel",
|
||||||
"orientation": "Orientation",
|
"orientation": "Orientação",
|
||||||
"portrait": "Portrait",
|
"portrait": "Retrato",
|
||||||
"landscape": "Landscape",
|
"landscape": "Paisagem",
|
||||||
"pagesPerSheet": "Pages per Sheet",
|
"pagesPerSheet": "Páginas por Folha",
|
||||||
"createBooklet": "Create Booklet",
|
"createBooklet": "Criar Livreto",
|
||||||
"processing": "Processing...",
|
"processing": "Processando...",
|
||||||
"pageCount": "Page count will be padded to multiple of 4 if needed."
|
"pageCount": "A contagem de páginas será ajustada para um múltiplo de 4, caso necessário."
|
||||||
},
|
},
|
||||||
"xpsToPdf": {
|
"xpsToPdf": {
|
||||||
"name": "XPS to PDF",
|
"name": "XPS para PDF",
|
||||||
"subtitle": "Convert XPS/OXPS documents to PDF format. Supports multiple files.",
|
"subtitle": "Converte XPS/OXPS para PDF. Suporta múltiplos arquivos.",
|
||||||
"acceptedFormats": "XPS, OXPS files",
|
"acceptedFormats": "Arquivos XPS, OXPS",
|
||||||
"convertButton": "Convert to PDF"
|
"convertButton": "Converter para PDF"
|
||||||
},
|
},
|
||||||
"mobiToPdf": {
|
"mobiToPdf": {
|
||||||
"name": "MOBI to PDF",
|
"name": "MOBI para PDF",
|
||||||
"subtitle": "Convert MOBI e-books to PDF format. Supports multiple files.",
|
"subtitle": "Converte e-books MOBI para PDF. Suporta múltiplos arquivos.",
|
||||||
"acceptedFormats": "MOBI files",
|
"acceptedFormats": "Arquivos MOBI",
|
||||||
"convertButton": "Convert to PDF"
|
"convertButton": "Converter para PDF"
|
||||||
},
|
},
|
||||||
"epubToPdf": {
|
"epubToPdf": {
|
||||||
"name": "EPUB to PDF",
|
"name": "EPUB para PDF",
|
||||||
"subtitle": "Convert EPUB e-books to PDF format. Supports multiple files.",
|
"subtitle": "Converte e-books EPUB para PDF. Suporta múltiplos arquivos.",
|
||||||
"acceptedFormats": "EPUB files",
|
"acceptedFormats": "Arquivos EPUB",
|
||||||
"convertButton": "Convert to PDF"
|
"convertButton": "Converter para PDF"
|
||||||
},
|
},
|
||||||
"fb2ToPdf": {
|
"fb2ToPdf": {
|
||||||
"name": "FB2 to PDF",
|
"name": "FB2 para PDF",
|
||||||
"subtitle": "Convert FictionBook (FB2) e-books to PDF format. Supports multiple files.",
|
"subtitle": "Converte e-books FictionBook (FB2) para PDF. Suporta múltiplos arquivos.",
|
||||||
"acceptedFormats": "FB2 files",
|
"acceptedFormats": "Arquivos FB2",
|
||||||
"convertButton": "Convert to PDF"
|
"convertButton": "Converter para PDF"
|
||||||
},
|
},
|
||||||
"cbzToPdf": {
|
"cbzToPdf": {
|
||||||
"name": "CBZ to PDF",
|
"name": "CBZ para PDF",
|
||||||
"subtitle": "Convert comic book archives (CBZ/CBR) to PDF format. Supports multiple files.",
|
"subtitle": "Converte comic book archives (CBZ/CBR) para PDF. Suporta múltiplos arquivos.",
|
||||||
"acceptedFormats": "CBZ, CBR files",
|
"acceptedFormats": "Arquivos CBZ, CBR",
|
||||||
"convertButton": "Convert to PDF"
|
"convertButton": "Converter para PDF"
|
||||||
},
|
},
|
||||||
"wpdToPdf": {
|
"wpdToPdf": {
|
||||||
"name": "WPD to PDF",
|
"name": "WPD para PDF",
|
||||||
"subtitle": "Convert WordPerfect documents (WPD) to PDF format. Supports multiple files.",
|
"subtitle": "Converte WordPerfect (WPD) para PDF. Suporta múltiplos arquivos.",
|
||||||
"acceptedFormats": "WPD files",
|
"acceptedFormats": "Arquivos WPD",
|
||||||
"convertButton": "Convert to PDF"
|
"convertButton": "Converter para PDF"
|
||||||
},
|
},
|
||||||
"wpsToPdf": {
|
"wpsToPdf": {
|
||||||
"name": "WPS to PDF",
|
"name": "WPS para PDF",
|
||||||
"subtitle": "Convert WPS Office documents to PDF format. Supports multiple files.",
|
"subtitle": "Converte WPS Office para PDF. Suporta múltiplos arquivos.",
|
||||||
"acceptedFormats": "WPS files",
|
"acceptedFormats": "Arquivos WPS",
|
||||||
"convertButton": "Convert to PDF"
|
"convertButton": "Converter para PDF"
|
||||||
},
|
},
|
||||||
"xmlToPdf": {
|
"xmlToPdf": {
|
||||||
"name": "XML to PDF",
|
"name": "XML para PDF",
|
||||||
"subtitle": "Convert XML documents to PDF format. Supports multiple files.",
|
"subtitle": "Converte XML para PDF. Suporta múltiplos arquivos.",
|
||||||
"acceptedFormats": "XML files",
|
"acceptedFormats": "Arquivos XML",
|
||||||
"convertButton": "Convert to PDF"
|
"convertButton": "Converter para PDF"
|
||||||
},
|
},
|
||||||
"pagesToPdf": {
|
"pagesToPdf": {
|
||||||
"name": "Pages to PDF",
|
"name": "Pages para PDF",
|
||||||
"subtitle": "Convert Apple Pages documents to PDF format. Supports multiple files.",
|
"subtitle": "Converte Apple Pages para PDF. Suporta múltiplos arquivos.",
|
||||||
"acceptedFormats": "Pages files",
|
"acceptedFormats": "Arquivos Pages",
|
||||||
"convertButton": "Convert to PDF"
|
"convertButton": "Converter para PDF"
|
||||||
},
|
},
|
||||||
"odgToPdf": {
|
"odgToPdf": {
|
||||||
"name": "ODG to PDF",
|
"name": "ODG para PDF",
|
||||||
"subtitle": "Convert OpenDocument Graphics (ODG) files to PDF format. Supports multiple files.",
|
"subtitle": "Converte OpenDocument Graphics (ODG) para PDF. Suporta múltiplos arquivos.",
|
||||||
"acceptedFormats": "ODG files",
|
"acceptedFormats": "Arquivos ODG",
|
||||||
"convertButton": "Convert to PDF"
|
"convertButton": "Converter para PDF"
|
||||||
},
|
},
|
||||||
"odsToPdf": {
|
"odsToPdf": {
|
||||||
"name": "ODS to PDF",
|
"name": "ODS para PDF",
|
||||||
"subtitle": "Convert OpenDocument Spreadsheet (ODS) files to PDF format. Supports multiple files.",
|
"subtitle": "Converte OpenDocument Spreadsheet (ODS) para PDF. Suporta múltiplos arquivos.",
|
||||||
"acceptedFormats": "ODS files",
|
"acceptedFormats": "Arquivos ODS",
|
||||||
"convertButton": "Convert to PDF"
|
"convertButton": "Converter para PDF"
|
||||||
},
|
},
|
||||||
"odpToPdf": {
|
"odpToPdf": {
|
||||||
"name": "ODP to PDF",
|
"name": "ODP para PDF",
|
||||||
"subtitle": "Convert OpenDocument Presentation (ODP) files to PDF format. Supports multiple files.",
|
"subtitle": "Converte OpenDocument Presentation (ODP) para PDF. Suporta múltiplos arquivos.",
|
||||||
"acceptedFormats": "ODP files",
|
"acceptedFormats": "Arquivos ODP",
|
||||||
"convertButton": "Convert to PDF"
|
"convertButton": "Converter para PDF"
|
||||||
},
|
},
|
||||||
"pubToPdf": {
|
"pubToPdf": {
|
||||||
"name": "PUB to PDF",
|
"name": "PUB para PDF",
|
||||||
"subtitle": "Convert Microsoft Publisher (PUB) files to PDF format. Supports multiple files.",
|
"subtitle": "Converte Microsoft Publisher (PUB) para PDF. Suporta múltiplos arquivos.",
|
||||||
"acceptedFormats": "PUB files",
|
"acceptedFormats": "Arquivos PUB",
|
||||||
"convertButton": "Convert to PDF"
|
"convertButton": "Converter para PDF"
|
||||||
},
|
},
|
||||||
"vsdToPdf": {
|
"vsdToPdf": {
|
||||||
"name": "VSD to PDF",
|
"name": "VSD para PDF",
|
||||||
"subtitle": "Convert Microsoft Visio (VSD, VSDX) files to PDF format. Supports multiple files.",
|
"subtitle": "Converte Microsoft Visio (VSD, VSDX) para PDF. Suporta múltiplos arquivos.",
|
||||||
"acceptedFormats": "VSD, VSDX files",
|
"acceptedFormats": "Arquivos VSD, VSDX",
|
||||||
"convertButton": "Convert to PDF"
|
"convertButton": "Converter para PDF"
|
||||||
},
|
},
|
||||||
"psdToPdf": {
|
"psdToPdf": {
|
||||||
"name": "PSD to PDF",
|
"name": "PSD para PDF",
|
||||||
"subtitle": "Convert Adobe Photoshop (PSD) files to PDF format. Supports multiple files.",
|
"subtitle": "Converte Adobe Photoshop (PSD) para PDF. Suporta múltiplos arquivos.",
|
||||||
"acceptedFormats": "PSD files",
|
"acceptedFormats": "Arquivos PSD",
|
||||||
"convertButton": "Convert to PDF"
|
"convertButton": "Converter para PDF"
|
||||||
},
|
},
|
||||||
"pdfToSvg": {
|
"pdfToSvg": {
|
||||||
"name": "PDF to SVG",
|
"name": "PDF para SVG",
|
||||||
"subtitle": "Convert each page of a PDF file into a scalable vector graphic (SVG) for perfect quality at any size."
|
"subtitle": "Converte cada página de um arquivo PDF em um scalable vector graphic (SVG) para qualidade perfeita em qualquer tamanho."
|
||||||
},
|
},
|
||||||
"extractTables": {
|
"extractTables": {
|
||||||
"name": "Extract PDF Tables",
|
"name": "Extrair Tabelas PDF",
|
||||||
"subtitle": "Extract tables from PDF files and export as CSV, JSON, or Markdown."
|
"subtitle": "Extrai tabelas de arquivos PDF e exporta como CSV, JSON, ou Markdown."
|
||||||
},
|
},
|
||||||
"pdfToCsv": {
|
"pdfToCsv": {
|
||||||
"name": "PDF to CSV",
|
"name": "PDF para CSV",
|
||||||
"subtitle": "Extract tables from PDF and convert to CSV format."
|
"subtitle": "Extrai tabelas do PDF e converte em CSV."
|
||||||
},
|
},
|
||||||
"pdfToExcel": {
|
"pdfToExcel": {
|
||||||
"name": "PDF to Excel",
|
"name": "PDF para Excel",
|
||||||
"subtitle": "Extract tables from PDF and convert to Excel (XLSX) format."
|
"subtitle": "Extrai tabelas do PDF e converte em Excel (XLSX)."
|
||||||
},
|
},
|
||||||
"pdfToText": {
|
"pdfToText": {
|
||||||
"name": "PDF to Text",
|
"name": "PDF para Texto",
|
||||||
"subtitle": "Extract text from PDF files and save as plain text (.txt). Supports multiple files.",
|
"subtitle": "Extrai texto de arquivos PDF e salva como texto simples (.txt). Suporta múltiplos arquivos.",
|
||||||
"note": "This tool works ONLY with digitally created PDFs. For scanned documents or image-based PDFs, use our OCR PDF tool instead.",
|
"note": "Esta ferrament funciona SOMENTE com PDFs criados digitalmente. Para documentos escaneados ou PDFs baseados em imagens, use nossa ferramenta OCR para PDF no lugar.",
|
||||||
"convertButton": "Extract Text"
|
"convertButton": "Extrair Texto"
|
||||||
},
|
},
|
||||||
"digitalSignPdf": {
|
"digitalSignPdf": {
|
||||||
"name": "Assinatura Digital PDF",
|
"name": "Assinatura Digital PDF",
|
||||||
|
|||||||
@@ -0,0 +1,365 @@
|
|||||||
|
{
|
||||||
|
"nav": {
|
||||||
|
"home": "Hem",
|
||||||
|
"about": "Om",
|
||||||
|
"contact": "Kontakt",
|
||||||
|
"licensing": "Licensiering",
|
||||||
|
"allTools": "Alla verktyg",
|
||||||
|
"openMainMenu": "Öppna huvudmenyn",
|
||||||
|
"language": "Språk"
|
||||||
|
},
|
||||||
|
"donation": {
|
||||||
|
"message": "Gillar du BentoPDF? Hjälp oss att hålla det gratis och öppen källkod!",
|
||||||
|
"button": "Donera"
|
||||||
|
},
|
||||||
|
"hero": {
|
||||||
|
"title": " ",
|
||||||
|
"pdfToolkit": "PDF-verktygslådan",
|
||||||
|
"builtForPrivacy": "byggd för integritet",
|
||||||
|
"noSignups": "Inga konton",
|
||||||
|
"unlimitedUse": "Obegränsad användning",
|
||||||
|
"worksOffline": "Fungerar offline",
|
||||||
|
"startUsing": "Börja använda nu"
|
||||||
|
},
|
||||||
|
"usedBy": {
|
||||||
|
"title": "Används av företag och personer som arbetar på"
|
||||||
|
},
|
||||||
|
"features": {
|
||||||
|
"title": "Varför välja",
|
||||||
|
"bentoPdf": "BentoPDF?",
|
||||||
|
"noSignup": {
|
||||||
|
"title": "Ingen registrering",
|
||||||
|
"description": "Börja direkt, inga konton eller e-postadresser."
|
||||||
|
},
|
||||||
|
"noUploads": {
|
||||||
|
"title": "Inga uppladdningar",
|
||||||
|
"description": "100% på klientsidan, dina filer lämnar aldrig din enhet."
|
||||||
|
},
|
||||||
|
"foreverFree": {
|
||||||
|
"title": "Gratis för alltid",
|
||||||
|
"description": "Alla verktyg, inga provperioder, inga betalväggar."
|
||||||
|
},
|
||||||
|
"noLimits": {
|
||||||
|
"title": "Inga begränsningar",
|
||||||
|
"description": "Använd så mycket du vill, inga dolda gränser."
|
||||||
|
},
|
||||||
|
"batchProcessing": {
|
||||||
|
"title": "Batchbearbetning",
|
||||||
|
"description": "Hantera obegränsade PDF-filer på en gång."
|
||||||
|
},
|
||||||
|
"lightningFast": {
|
||||||
|
"title": "Blixtrande snabb",
|
||||||
|
"description": "Bearbeta PDF-filer direkt, utan väntan eller förseningar."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"tools": {
|
||||||
|
"title": "Kom igång med",
|
||||||
|
"toolsLabel": "verktyg",
|
||||||
|
"subtitle": "Klicka på ett verktyg för att öppna filväljaren",
|
||||||
|
"searchPlaceholder": "Sök efter ett verktyg (t.ex. 'dela', 'organisera'...)",
|
||||||
|
"backToTools": "Tillbaka till verktyg",
|
||||||
|
"firstLoadNotice": "Första laddningen tar ett ögonblick eftersom vi hämtar vår konverteringsmotor. Därefter blir alla laddningar omedelbara."
|
||||||
|
},
|
||||||
|
"upload": {
|
||||||
|
"clickToSelect": "Klicka för att välja en fil",
|
||||||
|
"orDragAndDrop": "eller dra och släpp",
|
||||||
|
"pdfOrImages": "PDF-filer eller bilder",
|
||||||
|
"filesNeverLeave": "Dina filer lämnar aldrig din enhet.",
|
||||||
|
"addMore": "Lägg till fler filer",
|
||||||
|
"clearAll": "Rensa allt",
|
||||||
|
"clearFiles": "Rensa filer",
|
||||||
|
"hints": {
|
||||||
|
"singlePdf": "Ett enskilt PDF-dokument",
|
||||||
|
"pdfFile": "PDF-fil",
|
||||||
|
"multiplePdfs2": "Flera PDF-filer (minst 2)",
|
||||||
|
"bmpImages": "BMP-bilder",
|
||||||
|
"oneOrMorePdfs": "En eller flera PDF-filer",
|
||||||
|
"pdfDocuments": "PDF-dokument",
|
||||||
|
"oneOrMoreCsv": "En eller flera CSV-filer",
|
||||||
|
"multiplePdfsSupported": "Flera PDF-filer stöds",
|
||||||
|
"singleOrMultiplePdfs": "Enskild eller flera PDF-filer stöds",
|
||||||
|
"singlePdfFile": "Enskild PDF-fil",
|
||||||
|
"pdfWithForms": "PDF-fil med formulärfält",
|
||||||
|
"heicImages": "HEIC/HEIF-bilder",
|
||||||
|
"jpgImages": "JPG, JPEG, JP2, JPX-bilder",
|
||||||
|
"pdfsOrImages": "PDF-filer eller bilder",
|
||||||
|
"oneOrMoreOdt": "En eller flera ODT-filer",
|
||||||
|
"singlePdfOnly": "Endast enskild PDF-fil",
|
||||||
|
"pdfFiles": "PDF-filer",
|
||||||
|
"multiplePdfs": "Flera PDF-filer",
|
||||||
|
"pngImages": "PNG-bilder",
|
||||||
|
"pdfFilesOneOrMore": "PDF-filer (en eller flera)",
|
||||||
|
"oneOrMoreRtf": "En eller flera RTF-filer",
|
||||||
|
"svgGraphics": "SVG-grafik",
|
||||||
|
"tiffImages": "TIFF-bilder",
|
||||||
|
"webpImages": "WebP-bilder"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"howItWorks": {
|
||||||
|
"title": "Så här fungerar det",
|
||||||
|
"step1": "Klicka eller dra och släpp din fil för att börja",
|
||||||
|
"step2": "Klicka på bearbeta-knappen för att starta",
|
||||||
|
"step3": "Spara din bearbetade fil direkt"
|
||||||
|
},
|
||||||
|
"relatedTools": {
|
||||||
|
"title": "Relaterade PDF-verktyg"
|
||||||
|
},
|
||||||
|
"loader": {
|
||||||
|
"processing": "Bearbetar..."
|
||||||
|
},
|
||||||
|
"alert": {
|
||||||
|
"title": "Varning",
|
||||||
|
"ok": "OK"
|
||||||
|
},
|
||||||
|
"preview": {
|
||||||
|
"title": "Förhandsvisning",
|
||||||
|
"downloadAsPdf": "Ladda ner som PDF",
|
||||||
|
"close": "Stäng"
|
||||||
|
},
|
||||||
|
"settings": {
|
||||||
|
"title": "Inställningar",
|
||||||
|
"shortcuts": "Genvägar",
|
||||||
|
"preferences": "Inställningar",
|
||||||
|
"displayPreferences": "Visningsinställningar",
|
||||||
|
"searchShortcuts": "Sök genvägar...",
|
||||||
|
"shortcutsInfo": "Håll ned tangenterna för att ställa in en genväg. Ändringar sparas automatiskt.",
|
||||||
|
"shortcutsWarning": "⚠️ Undvik vanliga webbläsargenvägar (Cmd/Ctrl+W, Cmd/Ctrl+T, Cmd/Ctrl+N osv.) eftersom de kanske inte fungerar tillförlitligt.",
|
||||||
|
"import": "Importera",
|
||||||
|
"export": "Exportera",
|
||||||
|
"resetToDefaults": "Återställ till standard",
|
||||||
|
"fullWidthMode": "Fullbreddsläge",
|
||||||
|
"fullWidthDescription": "Använd hela skärmbredden för alla verktyg istället för en centrerad behållare",
|
||||||
|
"settingsAutoSaved": "Inställningar sparas automatiskt",
|
||||||
|
"clickToSet": "Klicka för att ställa in",
|
||||||
|
"pressKeys": "Tryck tangenter...",
|
||||||
|
"warnings": {
|
||||||
|
"alreadyInUse": "Genvägen används redan",
|
||||||
|
"assignedTo": "är redan tilldelad till:",
|
||||||
|
"chooseDifferent": "Välj en annan genväg.",
|
||||||
|
"reserved": "Reserverad genvägsvarning",
|
||||||
|
"commonlyUsed": "används vanligtvis för:",
|
||||||
|
"unreliable": "Den här genvägen fungerar kanske inte tillförlitligt eller kan komma i konflikt med webbläsarens/systemets beteende.",
|
||||||
|
"useAnyway": "Vill du använda den ändå?",
|
||||||
|
"resetTitle": "Återställ genvägar",
|
||||||
|
"resetMessage": "Är du säker på att du vill återställa alla genvägar till standard?<br><br>Den här åtgärden kan inte ångras.",
|
||||||
|
"importSuccessTitle": "Import lyckades",
|
||||||
|
"importSuccessMessage": "Genvägar importerades!",
|
||||||
|
"importFailTitle": "Import misslyckades",
|
||||||
|
"importFailMessage": "Det gick inte att importera genvägar. Ogiltigt filformat."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"warning": {
|
||||||
|
"title": "Varning",
|
||||||
|
"cancel": "Avbryt",
|
||||||
|
"proceed": "Fortsätt"
|
||||||
|
},
|
||||||
|
"compliance": {
|
||||||
|
"title": "Dina data lämnar aldrig din enhet",
|
||||||
|
"weKeep": "Vi håller",
|
||||||
|
"yourInfoSafe": "din information säker",
|
||||||
|
"byFollowingStandards": "genom att följa globala säkerhetsstandarder.",
|
||||||
|
"processingLocal": "All bearbetning sker lokalt på din enhet.",
|
||||||
|
"gdpr": {
|
||||||
|
"title": "GDPR-efterlevnad",
|
||||||
|
"description": "Skyddar personuppgifter och integritet för individer inom Europeiska unionen."
|
||||||
|
},
|
||||||
|
"ccpa": {
|
||||||
|
"title": "CCPA-efterlevnad",
|
||||||
|
"description": "Ger Kaliforniens residenter rättigheter över hur deras personliga information samlas in, används och delas."
|
||||||
|
},
|
||||||
|
"hipaa": {
|
||||||
|
"title": "HIPAA-efterlevnad",
|
||||||
|
"description": "Fastställer skyddsåtgärder för hantering av känslig hälsoinformation i det amerikanska sjukvårdssystemet."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"faq": {
|
||||||
|
"title": "Vanliga",
|
||||||
|
"questions": "frågor",
|
||||||
|
"sectionTitle": "Vanliga frågor och svar",
|
||||||
|
"isFree": {
|
||||||
|
"question": "Är BentoPDF verkligen gratis?",
|
||||||
|
"answer": "Ja, absolut. Alla verktyg på BentoPDF är 100% gratis att använda, utan filgränser, utan registreringar och utan vattenstämplar. Vi tror att alla förtjänar tillgång till enkla, kraftfulla PDF-verktyg utan en betalvägg."
|
||||||
|
},
|
||||||
|
"areFilesSecure": {
|
||||||
|
"question": "Är mina filer säkra? Var bearbetas de?",
|
||||||
|
"answer": "Dina filer är så säkra som möjligt eftersom de aldrig lämnar din dator. All bearbetning sker direkt i din webbläsare (klientsida). Vi laddar aldrig upp dina filer till en server, så du har fullständig integritet och kontroll över dina dokument."
|
||||||
|
},
|
||||||
|
"platforms": {
|
||||||
|
"question": "Fungerar det på Mac, Windows och mobil?",
|
||||||
|
"answer": "Ja! Eftersom BentoPDF körs helt i din webbläsare fungerar det på alla operativsystem med en modern webbläsare, inklusive Windows, macOS, Linux, iOS och Android."
|
||||||
|
},
|
||||||
|
"gdprCompliant": {
|
||||||
|
"question": "Är BentoPDF GDPR-kompatibelt?",
|
||||||
|
"answer": "Ja. BentoPDF är fullt GDPR-kompatibelt. Eftersom all filbearbetning sker lokalt i din webbläsare och vi aldrig samlar in eller överför dina filer till någon server, har vi ingen tillgång till dina data. Detta säkerställer att du alltid har kontroll över dina dokument."
|
||||||
|
},
|
||||||
|
"dataStorage": {
|
||||||
|
"question": "Lagrar eller spårar ni några av mina filer?",
|
||||||
|
"answer": "Nej. Vi lagrar, spårar eller loggar aldrig dina filer. Allt du gör på BentoPDF händer i din webbläsares minne och försvinner när du stänger sidan. Det finns inga uppladdningar, inga historikloggar och inga servrar inblandade."
|
||||||
|
},
|
||||||
|
"different": {
|
||||||
|
"question": "Vad gör BentoPDF annorlunda från andra PDF-verktyg?",
|
||||||
|
"answer": "De flesta PDF-verktyg laddar upp dina filer till en server för bearbetning. BentoPDF gör aldrig det. Vi använder säker, modern webbteknik för att bearbeta dina filer direkt i din webbläsare. Detta innebär snabbare prestanda, starkare integritet och fullständig trygghet."
|
||||||
|
},
|
||||||
|
"browserBased": {
|
||||||
|
"question": "Hur håller webbläsarbaserad bearbetning mig säker?",
|
||||||
|
"answer": "Genom att köras helt inne i din webbläsare säkerställer BentoPDF att dina filer aldrig lämnar din enhet. Detta eliminerar riskerna för serverhack, dataintrång eller obehörig åtkomst. Dina filer förblir dina - alltid."
|
||||||
|
},
|
||||||
|
"analytics": {
|
||||||
|
"question": "Använder ni cookies eller analys för att spåra mig?",
|
||||||
|
"answer": "Vi bryr oss om din integritet. BentoPDF spårar inte personlig information. Vi använder Simple Analytics enbart för att se anonyma besöksantal. Det betyder att vi kan se hur många användare som besöker vår site, men vi vet aldrig vem du är. Simple Analytics är fullt GDPR-kompatibelt och respekterar din integritet."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"testimonials": {
|
||||||
|
"title": "Vad våra",
|
||||||
|
"users": "användare",
|
||||||
|
"say": "säger"
|
||||||
|
},
|
||||||
|
"support": {
|
||||||
|
"title": "Gillar du mitt arbete?",
|
||||||
|
"description": "BentoPDF är ett passionerat projekt, byggt för att erbjuda en gratis, privat och kraftfull PDF-verktygslåda för alla. Om du tycker det är användbart, överväg att stödja utvecklingen. Varje kaffe hjälper!",
|
||||||
|
"buyMeCoffee": "Buy Me a Coffee"
|
||||||
|
},
|
||||||
|
"footer": {
|
||||||
|
"copyright": "© 2026 BentoPDF. Alla rättigheter reserverade.",
|
||||||
|
"version": "Version",
|
||||||
|
"company": "Företag",
|
||||||
|
"aboutUs": "Om oss",
|
||||||
|
"faqLink": "FAQ",
|
||||||
|
"contactUs": "Kontakta oss",
|
||||||
|
"legal": "Juridiskt",
|
||||||
|
"termsAndConditions": "Villkor",
|
||||||
|
"privacyPolicy": "Integritetspolicy",
|
||||||
|
"followUs": "Följ oss"
|
||||||
|
},
|
||||||
|
"merge": {
|
||||||
|
"title": "Sammanfoga PDF:er",
|
||||||
|
"description": "Kombinera hela filer eller välj specifika sidor att sammanfoga till ett nytt dokument.",
|
||||||
|
"fileMode": "Filläge",
|
||||||
|
"pageMode": "Sidoläge",
|
||||||
|
"howItWorks": "Så här fungerar det:",
|
||||||
|
"fileModeInstructions": [
|
||||||
|
"Klicka och dra ikonen för att ändra ordningen på filerna.",
|
||||||
|
"I rutan \"Sidor\" för varje fil kan du ange intervall (t.ex. \"1-3, 5\") för att sammanfoga endast de sidorna.",
|
||||||
|
"Lämna rutan \"Sidor\" tom för att inkludera alla sidor från den filen."
|
||||||
|
],
|
||||||
|
"pageModeInstructions": [
|
||||||
|
"Alla sidor från dina uppladdade PDF:er visas nedan.",
|
||||||
|
"Dra och släpp sidminiyrbilderna för att skapa den ordning du vill ha för din nya fil."
|
||||||
|
],
|
||||||
|
"mergePdfs": "Sammanfoga PDF:er"
|
||||||
|
},
|
||||||
|
"common": {
|
||||||
|
"page": "Sida",
|
||||||
|
"pages": "Sidor",
|
||||||
|
"of": "av",
|
||||||
|
"download": "Ladda ner",
|
||||||
|
"cancel": "Avbryt",
|
||||||
|
"save": "Spara",
|
||||||
|
"delete": "Ta bort",
|
||||||
|
"edit": "Redigera",
|
||||||
|
"add": "Lägg till",
|
||||||
|
"remove": "Ta bort",
|
||||||
|
"loading": "Laddar...",
|
||||||
|
"error": "Fel",
|
||||||
|
"success": "Klart",
|
||||||
|
"file": "Fil",
|
||||||
|
"files": "Filer",
|
||||||
|
"close": "Stäng"
|
||||||
|
},
|
||||||
|
"about": {
|
||||||
|
"hero": {
|
||||||
|
"title": "Vi tror att PDF-verktyg ska vara",
|
||||||
|
"subtitle": "snabba, privata och gratis.",
|
||||||
|
"noCompromises": "Inga kompromisser."
|
||||||
|
},
|
||||||
|
"mission": {
|
||||||
|
"title": "Vårt uppdrag",
|
||||||
|
"description": "Att tillhandahålla den mest omfattande PDF-verktygslådan som respekterar din integritet och aldrig ber om betalning. Vi tycker att viktiga dokumentverktyg ska vara tillgängliga för alla, överallt, utan hinder."
|
||||||
|
},
|
||||||
|
"philosophy": {
|
||||||
|
"label": "Vår kärnfilosofi",
|
||||||
|
"title": "Integritet först. Alltid.",
|
||||||
|
"description": "I en tid där data är en handelsvara tar vi en annan väg. All bearbetning i BentoPDF sker lokalt i din webbläsare. Detta dina filer betyder att aldrig rör våra servrar, vi ser aldrig dina dokument och vi spårar inte vad du gör. Dina dokument förblir helt och hållet privata. Det är inte bara en funktion - det är vår grund."
|
||||||
|
},
|
||||||
|
"whyBentopdf": {
|
||||||
|
"title": "Varför",
|
||||||
|
"speed": {
|
||||||
|
"title": "Byggd för hastighet",
|
||||||
|
"description": "Ingen väntan på uppladdningar eller nedladdningar till en server. Genom att bearbeta filer direkt i din webbläsare med moderna webbteknologier som WebAssembly erbjuder vi oöverträffad hastighet för alla våra verktyg."
|
||||||
|
},
|
||||||
|
"free": {
|
||||||
|
"title": "Helt gratis",
|
||||||
|
"description": "Inga provperioder, inga prenumerationer, inga dolda avgifter och inga \"premium\"-funktioner bakom en spärr. Vi tycker att kraftfulla PDF-verktyg ska vara en allmän nyttighet, inte en vinstmaskin."
|
||||||
|
},
|
||||||
|
"noAccount": {
|
||||||
|
"title": "Inget konto krävs",
|
||||||
|
"description": "Börja använda vilket verktyg som helst direkt. Vi behöver inte din e-post, ett lösenord eller någon personlig information. Ditt arbetsflöde ska vara friktionsfritt och anonymt."
|
||||||
|
},
|
||||||
|
"openSource": {
|
||||||
|
"title": "Öppen källkod-anda",
|
||||||
|
"description": "Byggd med transparens i åtanke. Vi använder fantastiska öppna källkodsbibliotek som PDF-lib och PDF.js, och tror på community-driven utveckling för att göra kraftfulla verktyg tillgängliga för alla."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"cta": {
|
||||||
|
"title": "Redo att komma igång?",
|
||||||
|
"description": "Gå med i tusentals användare som litar på Bentopdf för sina dagliga dokumentbehov. Upplev skillnaden som integritet och prestanda kan göra.",
|
||||||
|
"button": "Utforska alla verktyg"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"contact": {
|
||||||
|
"title": "Kontakta oss",
|
||||||
|
"subtitle": "Vi vill gärna höra från dig. Oavsett om du har frågor, feedback eller funktionsförslag - tveka inte att höra av dig.",
|
||||||
|
"email": "Du kan nå oss direkt via e-post på:"
|
||||||
|
},
|
||||||
|
"licensing": {
|
||||||
|
"title": "Licenser för",
|
||||||
|
"subtitle": "Välj den licens som passar dina behov."
|
||||||
|
},
|
||||||
|
"multiTool": {
|
||||||
|
"uploadPdfs": "Ladda upp PDF:er",
|
||||||
|
"upload": "Ladda upp",
|
||||||
|
"addBlankPage": "Lägg till tom sida",
|
||||||
|
"edit": "Redigera:",
|
||||||
|
"undo": "Ångra",
|
||||||
|
"redo": "Gör om",
|
||||||
|
"reset": "Återställ",
|
||||||
|
"selection": "Markering:",
|
||||||
|
"selectAll": "Markera alla",
|
||||||
|
"deselectAll": "Avmarkera alla",
|
||||||
|
"rotate": "Rotera:",
|
||||||
|
"rotateLeft": "Vänster",
|
||||||
|
"rotateRight": "Höger",
|
||||||
|
"transform": "Transformera:",
|
||||||
|
"duplicate": "Duplicera",
|
||||||
|
"split": "Dela",
|
||||||
|
"clear": "Rensa:",
|
||||||
|
"delete": "Ta bort",
|
||||||
|
"download": "Ladda ner:",
|
||||||
|
"downloadSelected": "Ladda ner markerade",
|
||||||
|
"exportPdf": "Exportera PDF",
|
||||||
|
"uploadPdfFiles": "Välj PDF-filer",
|
||||||
|
"dragAndDrop": "Dra och släpp PDF-filer här, eller klicka för att välja",
|
||||||
|
"selectFiles": "Välj filer",
|
||||||
|
"renderingPages": "Renderar sidor...",
|
||||||
|
"actions": {
|
||||||
|
"duplicatePage": "Duplicera denna sida",
|
||||||
|
"deletePage": "Ta bort denna sida",
|
||||||
|
"insertPdf": "Infoga PDF efter denna sida",
|
||||||
|
"toggleSplit": "Växla delning efter denna sida"
|
||||||
|
},
|
||||||
|
"pleaseWait": "Vänligen vänta",
|
||||||
|
"pagesRendering": "Sidor renderas fortfarande. Vänligen vänta...",
|
||||||
|
"noPagesSelected": "Inga sidor markerade",
|
||||||
|
"selectOnePage": "Vänligen välj minst en sida att ladda ner.",
|
||||||
|
"noPages": "Inga sidor",
|
||||||
|
"noPagesToExport": "Det finns inga sidor att exportera.",
|
||||||
|
"renderingTitle": "Renderar sidförhandsvisningar",
|
||||||
|
"errorRendering": "Det gick inte att rendera sidminiatyrer",
|
||||||
|
"error": "Fel",
|
||||||
|
"failedToLoad": "Det gick inte att ladda"
|
||||||
|
},
|
||||||
|
"simpleMode": {
|
||||||
|
"title": "PDF-verktyg",
|
||||||
|
"subtitle": "Välj ett verktyg för att komma igång"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,631 @@
|
|||||||
|
{
|
||||||
|
"categories": {
|
||||||
|
"popularTools": "Populära verktyg",
|
||||||
|
"editAnnotate": "Redigera & Kommentera",
|
||||||
|
"convertToPdf": "Konvertera till PDF",
|
||||||
|
"convertFromPdf": "Konvertera från PDF",
|
||||||
|
"organizeManage": "Organisera & Hantera",
|
||||||
|
"optimizeRepair": "Optimera & Reparera",
|
||||||
|
"securePdf": "Säkra PDF"
|
||||||
|
},
|
||||||
|
"pdfMultiTool": {
|
||||||
|
"name": "PDF multiverktyg",
|
||||||
|
"subtitle": "Sammanfoga, dela, organisera, ta bort, rotera, lägg till tomma sidor, extrahera och duplicera i ett enhetligt gränssnitt."
|
||||||
|
},
|
||||||
|
"mergePdf": {
|
||||||
|
"name": "Sammanfoga PDF",
|
||||||
|
"subtitle": "Kombinera flera PDF-filer till en fil. Bevarar bokmärken."
|
||||||
|
},
|
||||||
|
"splitPdf": {
|
||||||
|
"name": "Dela PDF",
|
||||||
|
"subtitle": "Extrahera ett intervall av sidor till en ny PDF."
|
||||||
|
},
|
||||||
|
"compressPdf": {
|
||||||
|
"name": "Komprimera PDF",
|
||||||
|
"subtitle": "Minska filstorleken på din PDF.",
|
||||||
|
"algorithmLabel": "Komprimeringsalgoritm",
|
||||||
|
"condense": "Kondensera (Rekommenderas)",
|
||||||
|
"photon": "Foton (För foto-tunga PDF:er)",
|
||||||
|
"condenseInfo": "Kondensera använder avancerad komprimering: tar bort död vikt, optimerar bilder, undergrupperar typsnitt. Bäst för de flesta PDF:er.",
|
||||||
|
"photonInfo": "Foton konverterar sidor till bilder. Använd för foto-tunga/scannade PDF:er.",
|
||||||
|
"photonWarning": "Varning: Text blir icke-valbar och länkarna slutar fungera.",
|
||||||
|
"levelLabel": "Komprimeringsnivå",
|
||||||
|
"light": "Lätt (Bevara kvalitet)",
|
||||||
|
"balanced": "Balanserad (Rekommenderas)",
|
||||||
|
"aggressive": "Aggressiv (Mindre filer)",
|
||||||
|
"extreme": "Extreme (Maximal komprimering)",
|
||||||
|
"grayscale": "Konvertera till gråskala",
|
||||||
|
"grayscaleHint": "Minskar filstorleken genom att ta bort färginformation",
|
||||||
|
"customSettings": "Anpassade inställningar",
|
||||||
|
"customSettingsHint": "Finjustera komprimeringsparametrar:",
|
||||||
|
"outputQuality": "Utdatakvalitet",
|
||||||
|
"resizeImagesTo": "Ändra bildstorlek till",
|
||||||
|
"onlyProcessAbove": "Bearbeta endast över",
|
||||||
|
"removeMetadata": "Ta bort metadata",
|
||||||
|
"subsetFonts": "Undergruppera typsnitt (ta bort oanvända glyfer)",
|
||||||
|
"removeThumbnails": "Ta bort inbäddade miniatyrer",
|
||||||
|
"compressButton": "Komprimera PDF"
|
||||||
|
},
|
||||||
|
"pdfEditor": {
|
||||||
|
"name": "PDF-redigerare",
|
||||||
|
"subtitle": "Kommentera, markera, redigera, kommentera, lägg till former/bilder, sök och visa PDF:er."
|
||||||
|
},
|
||||||
|
"jpgToPdf": {
|
||||||
|
"name": "JPG till PDF",
|
||||||
|
"subtitle": "Skapa en PDF från JPG, JPEG och JPEG2000 (JP2/JPX)-bilder."
|
||||||
|
},
|
||||||
|
"signPdf": {
|
||||||
|
"name": "Signera PDF",
|
||||||
|
"subtitle": "Rita, skriv eller ladda upp din signatur."
|
||||||
|
},
|
||||||
|
"cropPdf": {
|
||||||
|
"name": "Beskär PDF",
|
||||||
|
"subtitle": "Beskär marginalerna på varje sida i din PDF."
|
||||||
|
},
|
||||||
|
"extractPages": {
|
||||||
|
"name": "Extrahera sidor",
|
||||||
|
"subtitle": "Spara ett urval av sidor som nya filer."
|
||||||
|
},
|
||||||
|
"duplicateOrganize": {
|
||||||
|
"name": "Duplicera & organisera",
|
||||||
|
"subtitle": "Duplicera, ordna om och ta bort sidor."
|
||||||
|
},
|
||||||
|
"deletePages": {
|
||||||
|
"name": "Ta bort sidor",
|
||||||
|
"subtitle": "Ta bort specifika sidor från ditt dokument."
|
||||||
|
},
|
||||||
|
"editBookmarks": {
|
||||||
|
"name": "Redigera bokmärken",
|
||||||
|
"subtitle": "Lägg till, redigera, importera, ta bort och extrahera PDF-bokmärken."
|
||||||
|
},
|
||||||
|
"tableOfContents": {
|
||||||
|
"name": "Innehållsförteckning",
|
||||||
|
"subtitle": "Generera en innehållsförteckningssida från PDF-bokmärken."
|
||||||
|
},
|
||||||
|
"pageNumbers": {
|
||||||
|
"name": "Sidnummer",
|
||||||
|
"subtitle": "Infoga sidnummer i ditt dokument."
|
||||||
|
},
|
||||||
|
"batesNumbering": {
|
||||||
|
"name": "Bates-numrering",
|
||||||
|
"subtitle": "Lägg till sekventiella Bates-nummer över en eller flera PDF-filer."
|
||||||
|
},
|
||||||
|
"addWatermark": {
|
||||||
|
"name": "Lägg till vattenstämpel",
|
||||||
|
"subtitle": "Stämpla text eller en bild över dina PDF-sidor.",
|
||||||
|
"applyToAllPages": "Applicera på alla sidor"
|
||||||
|
},
|
||||||
|
"headerFooter": {
|
||||||
|
"name": "Sidhuvud & sidfot",
|
||||||
|
"subtitle": "Lägg till text överst och längst ner på sidor."
|
||||||
|
},
|
||||||
|
"invertColors": {
|
||||||
|
"name": "Invertera färger",
|
||||||
|
"subtitle": "Skapa en \"mörkt läge\"-version av din PDF."
|
||||||
|
},
|
||||||
|
"scannerEffect": {
|
||||||
|
"name": "Skannereffekt",
|
||||||
|
"subtitle": "Få din PDF att se ut som ett scannat dokument.",
|
||||||
|
"scanSettings": "Skannerinställningar",
|
||||||
|
"colorspace": "Färgrymd",
|
||||||
|
"gray": "Grå",
|
||||||
|
"border": "Kant",
|
||||||
|
"rotate": "Rotera",
|
||||||
|
"rotateVariance": "Rotationsvarians",
|
||||||
|
"brightness": "Ljushet",
|
||||||
|
"contrast": "Kontrast",
|
||||||
|
"blur": "Oskärpa",
|
||||||
|
"noise": "Brus",
|
||||||
|
"yellowish": "Gulaktig",
|
||||||
|
"resolution": "Upplösning",
|
||||||
|
"processButton": "Applicera skannereffekt"
|
||||||
|
},
|
||||||
|
"adjustColors": {
|
||||||
|
"name": "Justera färger",
|
||||||
|
"subtitle": "Finjustera ljusstyrka, kontrast, mättnad och mer i din PDF.",
|
||||||
|
"colorSettings": "Färginställningar",
|
||||||
|
"brightness": "Ljushet",
|
||||||
|
"contrast": "Kontrast",
|
||||||
|
"saturation": "Mättnad",
|
||||||
|
"hueShift": "Nyansskift",
|
||||||
|
"temperature": "Temperatur",
|
||||||
|
"tint": "Ton",
|
||||||
|
"gamma": "Gamma",
|
||||||
|
"sepia": "Sepia",
|
||||||
|
"processButton": "Applicera färgjusteringar"
|
||||||
|
},
|
||||||
|
"backgroundColor": {
|
||||||
|
"name": "Bakgrundsfärg",
|
||||||
|
"subtitle": "Ändra bakgrundsfärgen på din PDF."
|
||||||
|
},
|
||||||
|
"changeTextColor": {
|
||||||
|
"name": "Ändra textfärg",
|
||||||
|
"subtitle": "Ändra färgen på text i din PDF."
|
||||||
|
},
|
||||||
|
"addStamps": {
|
||||||
|
"name": "Lägg till stämplar",
|
||||||
|
"subtitle": "Lägg till bildstämplar på din PDF med kommentarsverktygsfältet.",
|
||||||
|
"usernameLabel": "Stämpelanvändarnamn",
|
||||||
|
"usernamePlaceholder": "Ange ditt namn (för stämplar)",
|
||||||
|
"usernameHint": "Detta namn kommer att visas på stämplar du skapar."
|
||||||
|
},
|
||||||
|
"removeAnnotations": {
|
||||||
|
"name": "Ta bort anteckningar",
|
||||||
|
"subtitle": "Ta bort kommentarer, markeringar och länkade."
|
||||||
|
},
|
||||||
|
"pdfFormFiller": {
|
||||||
|
"name": "PDF-formulärifyllare",
|
||||||
|
"subtitle": "Fyll i formulär direkt i webbläsaren. Stöder även XFA-formulär."
|
||||||
|
},
|
||||||
|
"createPdfForm": {
|
||||||
|
"name": "Skapa PDF-formulär",
|
||||||
|
"subtitle": "Skapa ifyllbara PDF-formulär med dra-och-släpp textfält."
|
||||||
|
},
|
||||||
|
"removeBlankPages": {
|
||||||
|
"name": "Ta bort tomma sidor",
|
||||||
|
"subtitle": "Automatiskt identifiera och ta bort tomma sidor.",
|
||||||
|
"sensitivityHint": "Högre = striktare, bara helt tomma sidor. Lägre = tillåter sidor med visst innehåll."
|
||||||
|
},
|
||||||
|
"imageToPdf": {
|
||||||
|
"name": "Bilder till PDF",
|
||||||
|
"subtitle": "Konvertera JPG, PNG, BMP, GIF, TIFF, PNM, PGM, PBM, PPM, PAM, JXR, JPX, JP2, PSD, SVG, HEIC, WebP till PDF."
|
||||||
|
},
|
||||||
|
"pngToPdf": {
|
||||||
|
"name": "PNG till PDF",
|
||||||
|
"subtitle": "Skapa en PDF från en eller flera PNG-bilder."
|
||||||
|
},
|
||||||
|
"webpToPdf": {
|
||||||
|
"name": "WebP till PDF",
|
||||||
|
"subtitle": "Skapa en PDF från en eller flera WebP-bilder."
|
||||||
|
},
|
||||||
|
"svgToPdf": {
|
||||||
|
"name": "SVG till PDF",
|
||||||
|
"subtitle": "Skapa en PDF från en eller flera SVG-bilder."
|
||||||
|
},
|
||||||
|
"bmpToPdf": {
|
||||||
|
"name": "BMP till PDF",
|
||||||
|
"subtitle": "Skapa en PDF från en eller flera BMP-bilder."
|
||||||
|
},
|
||||||
|
"heicToPdf": {
|
||||||
|
"name": "HEIC till PDF",
|
||||||
|
"subtitle": "Skapa en PDF från en eller flera HEIC-bilder."
|
||||||
|
},
|
||||||
|
"tiffToPdf": {
|
||||||
|
"name": "TIFF till PDF",
|
||||||
|
"subtitle": "Skapa en PDF från en eller flera TIFF-bilder."
|
||||||
|
},
|
||||||
|
"textToPdf": {
|
||||||
|
"name": "Text till PDF",
|
||||||
|
"subtitle": "Konvertera en vanlig textfil till en PDF."
|
||||||
|
},
|
||||||
|
"jsonToPdf": {
|
||||||
|
"name": "JSON till PDF",
|
||||||
|
"subtitle": "Konvertera JSON-filer till PDF-format."
|
||||||
|
},
|
||||||
|
"pdfToJpg": {
|
||||||
|
"name": "PDF till JPG",
|
||||||
|
"subtitle": "Konvertera varje PDF-sida till en JPG-bild."
|
||||||
|
},
|
||||||
|
"pdfToPng": {
|
||||||
|
"name": "PDF till PNG",
|
||||||
|
"subtitle": "Konvertera varje PDF-sida till en PNG-bild."
|
||||||
|
},
|
||||||
|
"pdfToWebp": {
|
||||||
|
"name": "PDF till WebP",
|
||||||
|
"subtitle": "Konvertera varje PDF-sida till en WebP-bild."
|
||||||
|
},
|
||||||
|
"pdfToBmp": {
|
||||||
|
"name": "PDF till BMP",
|
||||||
|
"subtitle": "Konvertera varje PDF-sida till en BMP-bild."
|
||||||
|
},
|
||||||
|
"pdfToTiff": {
|
||||||
|
"name": "PDF till TIFF",
|
||||||
|
"subtitle": "Konvertera varje PDF-sida till en TIFF-bild."
|
||||||
|
},
|
||||||
|
"pdfToGreyscale": {
|
||||||
|
"name": "PDF till gråskala",
|
||||||
|
"subtitle": "Konvertera alla färger till svart och vitt."
|
||||||
|
},
|
||||||
|
"pdfToJson": {
|
||||||
|
"name": "PDF till JSON",
|
||||||
|
"subtitle": "Konvertera PDF-filer till JSON-format."
|
||||||
|
},
|
||||||
|
"ocrPdf": {
|
||||||
|
"name": "OCR PDF",
|
||||||
|
"subtitle": "Gör en PDF sökbar och kopiaerbar."
|
||||||
|
},
|
||||||
|
"alternateMix": {
|
||||||
|
"name": "Alternera & blanda sidor",
|
||||||
|
"subtitle": "Sammanfoga PDF:er genom att alternera sidor från varje PDF. Bevarar bokmärken."
|
||||||
|
},
|
||||||
|
"addAttachments": {
|
||||||
|
"name": "Lägg till bilagor",
|
||||||
|
"subtitle": "Bädda in en eller flera filer i din PDF."
|
||||||
|
},
|
||||||
|
"extractAttachments": {
|
||||||
|
"name": "Extrahera bilagor",
|
||||||
|
"subtitle": "Extrahera alla inbäddade filer från PDF(er) som en ZIP."
|
||||||
|
},
|
||||||
|
"editAttachments": {
|
||||||
|
"name": "Redigera bilagor",
|
||||||
|
"subtitle": "Visa eller ta bort bilagor i din PDF."
|
||||||
|
},
|
||||||
|
"dividePages": {
|
||||||
|
"name": "Dela sidor",
|
||||||
|
"subtitle": "Dela sidor horisontellt eller vertikalt."
|
||||||
|
},
|
||||||
|
"addBlankPage": {
|
||||||
|
"name": "Lägg till tom sida",
|
||||||
|
"subtitle": "Infoga en tom sida var som helst i din PDF."
|
||||||
|
},
|
||||||
|
"reversePages": {
|
||||||
|
"name": "Omvänd sidor",
|
||||||
|
"subtitle": "Vänd ordningen på alla sidor i ditt dokument."
|
||||||
|
},
|
||||||
|
"rotatePdf": {
|
||||||
|
"name": "Rotera PDF",
|
||||||
|
"subtitle": "Vrid sidor i 90-graderssteg."
|
||||||
|
},
|
||||||
|
"rotateCustom": {
|
||||||
|
"name": "Rotera efter anpassade grader",
|
||||||
|
"subtitle": "Rotera sidor efter valfri vinkel."
|
||||||
|
},
|
||||||
|
"nUpPdf": {
|
||||||
|
"name": "N-Up PDF",
|
||||||
|
"subtitle": "Ordna flera sidor på ett enda ark."
|
||||||
|
},
|
||||||
|
"combineToSinglePage": {
|
||||||
|
"name": "Kombinera till enskild sida",
|
||||||
|
"subtitle": "Sy ihop alla sidor till en kontinuerlig rullning."
|
||||||
|
},
|
||||||
|
"viewMetadata": {
|
||||||
|
"name": "Visa metadata",
|
||||||
|
"subtitle": "Granska de dolda egenskaperna i din PDF."
|
||||||
|
},
|
||||||
|
"editMetadata": {
|
||||||
|
"name": "Redigera metadata",
|
||||||
|
"subtitle": "Ändra författaren, titeln och andra egenskaper."
|
||||||
|
},
|
||||||
|
"pdfsToZip": {
|
||||||
|
"name": "PDF:er till ZIP",
|
||||||
|
"subtitle": "Paketera flera PDF-filer till ett ZIP-arkiv."
|
||||||
|
},
|
||||||
|
"comparePdfs": {
|
||||||
|
"name": "Jämför PDF:er",
|
||||||
|
"subtitle": "Jämför två PDF:er bredvid varandra."
|
||||||
|
},
|
||||||
|
"posterizePdf": {
|
||||||
|
"name": "Postera PDF",
|
||||||
|
"subtitle": "Dela en stor sida i flera mindre sidor."
|
||||||
|
},
|
||||||
|
"fixPageSize": {
|
||||||
|
"name": "Fixa sidstorlek",
|
||||||
|
"subtitle": "Standardisera alla sidor till en enhetlig storlek."
|
||||||
|
},
|
||||||
|
"linearizePdf": {
|
||||||
|
"name": "Linearizera PDF",
|
||||||
|
"subtitle": "Optimera PDF för snabb webbvisning."
|
||||||
|
},
|
||||||
|
"pageDimensions": {
|
||||||
|
"name": "Sidmått",
|
||||||
|
"subtitle": "Analysera sidstorlek, orientering och enheter."
|
||||||
|
},
|
||||||
|
"removeRestrictions": {
|
||||||
|
"name": "Ta bort begränsningar",
|
||||||
|
"subtitle": "Ta bort lösenordsskydd och säkerhetsbegränsningar associerade med digitalt signerade PDF-filer."
|
||||||
|
},
|
||||||
|
"repairPdf": {
|
||||||
|
"name": "Reparera PDF",
|
||||||
|
"subtitle": "Återställ data från skadade eller korrupta PDF-filer."
|
||||||
|
},
|
||||||
|
"encryptPdf": {
|
||||||
|
"name": "Kryptera PDF",
|
||||||
|
"subtitle": "Lås din PDF genom att lägga till ett lösenord."
|
||||||
|
},
|
||||||
|
"sanitizePdf": {
|
||||||
|
"name": "Sanera PDF",
|
||||||
|
"subtitle": "Ta bort metadata, anteckningar, skript och mer."
|
||||||
|
},
|
||||||
|
"decryptPdf": {
|
||||||
|
"name": "Dekryptera PDF",
|
||||||
|
"subtitle": "Lås upp PDF genom att ta bort lösenordsskydd."
|
||||||
|
},
|
||||||
|
"flattenPdf": {
|
||||||
|
"name": "Platta till PDF",
|
||||||
|
"subtitle": "Gör formulärfält och anteckningar icke-redigerbara."
|
||||||
|
},
|
||||||
|
"removeMetadata": {
|
||||||
|
"name": "Ta bort metadata",
|
||||||
|
"subtitle": "Ta bort dold data från din PDF."
|
||||||
|
},
|
||||||
|
"changePermissions": {
|
||||||
|
"name": "Ändra behörigheter",
|
||||||
|
"subtitle": "Ställ in eller ändra användarbehörigheter på en PDF."
|
||||||
|
},
|
||||||
|
"odtToPdf": {
|
||||||
|
"name": "ODT till PDF",
|
||||||
|
"subtitle": "Konvertera OpenDocument Text-filer till PDF-format. Stöder flera filer.",
|
||||||
|
"acceptedFormats": "ODT-filer",
|
||||||
|
"convertButton": "Konvertera till PDF"
|
||||||
|
},
|
||||||
|
"csvToPdf": {
|
||||||
|
"name": "CSV till PDF",
|
||||||
|
"subtitle": "Konvertera CSV-kalkylarksfiler till PDF-format. Stöder flera filer.",
|
||||||
|
"acceptedFormats": "CSV-filer",
|
||||||
|
"convertButton": "Konvertera till PDF"
|
||||||
|
},
|
||||||
|
"rtfToPdf": {
|
||||||
|
"name": "RTF till PDF",
|
||||||
|
"subtitle": "Konvertera Rich Text Format-dokument till PDF. Stöder flera filer.",
|
||||||
|
"acceptedFormats": "RTF-filer",
|
||||||
|
"convertButton": "Konvertera till PDF"
|
||||||
|
},
|
||||||
|
"wordToPdf": {
|
||||||
|
"name": "Word till PDF",
|
||||||
|
"subtitle": "Konvertera Word-dokument (DOCX, DOC, ODT, RTF) till PDF-format. Stöder flera filer.",
|
||||||
|
"acceptedFormats": "DOCX, DOC, ODT, RTF-filer",
|
||||||
|
"convertButton": "Konvertera till PDF"
|
||||||
|
},
|
||||||
|
"excelToPdf": {
|
||||||
|
"name": "Excel till PDF",
|
||||||
|
"subtitle": "Konvertera Excel-kalkylark (XLSX, XLS, ODS, CSV) till PDF-format. Stöder flera filer.",
|
||||||
|
"acceptedFormats": "XLSX, XLS, ODS, CSV-filer",
|
||||||
|
"convertButton": "Konvertera till PDF"
|
||||||
|
},
|
||||||
|
"powerpointToPdf": {
|
||||||
|
"name": "PowerPoint till PDF",
|
||||||
|
"subtitle": "Konvertera PowerPoint-presentationer (PPTX, PPT, ODP) till PDF-format. Stöder flera filer.",
|
||||||
|
"acceptedFormats": "PPTX, PPT, ODP-filer",
|
||||||
|
"convertButton": "Konvertera till PDF"
|
||||||
|
},
|
||||||
|
"markdownToPdf": {
|
||||||
|
"name": "Markdown till PDF",
|
||||||
|
"subtitle": "Skriv eller klistra in Markdown och exportera som en vackert formaterad PDF.",
|
||||||
|
"paneMarkdown": "Markdown",
|
||||||
|
"panePreview": "Förhandsvisning",
|
||||||
|
"btnUpload": "Ladda upp",
|
||||||
|
"btnSyncScroll": "Synkronisera scroll",
|
||||||
|
"btnSettings": "Inställningar",
|
||||||
|
"btnExportPdf": "Exportera PDF",
|
||||||
|
"settingsTitle": "Markdown-inställningar",
|
||||||
|
"settingsPreset": "Förinställning",
|
||||||
|
"presetDefault": "Standard (GFM-liknande)",
|
||||||
|
"presetCommonmark": "CommonMark (strikt)",
|
||||||
|
"presetZero": "Minimal (inga funktioner)",
|
||||||
|
"settingsOptions": "Markdown-alternativ",
|
||||||
|
"optAllowHtml": "Tillåt HTML-taggar",
|
||||||
|
"optBreaks": "Konvertera radbrytningar till <br>",
|
||||||
|
"optLinkify": "Auto-konvertera URL:er till länkade",
|
||||||
|
"optTypographer": "Typograf (smarta citattecken, etc.)"
|
||||||
|
},
|
||||||
|
"pdfBooklet": {
|
||||||
|
"name": "PDF-broschyr",
|
||||||
|
"subtitle": "Ordna om sidor för dubbelsidig broschyutskrift. Vik och häfta för att skapa en broschyr.",
|
||||||
|
"howItWorks": "Så här fungerar det:",
|
||||||
|
"step1": "Ladda upp en PDF-fil.",
|
||||||
|
"step2": "Sidor kommer att ordnas om i broschyrordning.",
|
||||||
|
"step3": "Skriv ut dubbelsidigt, vik på kort kant, vik och häfta.",
|
||||||
|
"paperSize": "Pappersstorlek",
|
||||||
|
"orientation": "Orientering",
|
||||||
|
"portrait": "Stående",
|
||||||
|
"landscape": "Liggande",
|
||||||
|
"pagesPerSheet": "Sidor per ark",
|
||||||
|
"createBooklet": "Skapa broschyr",
|
||||||
|
"processing": "Bearbetar...",
|
||||||
|
"pageCount": "Sidantal kommer att fyllas ut till multipel av 4 om det behövs."
|
||||||
|
},
|
||||||
|
"xpsToPdf": {
|
||||||
|
"name": "XPS till PDF",
|
||||||
|
"subtitle": "Konvertera XPS/OXPS-dokument till PDF-format. Stöder flera filer.",
|
||||||
|
"acceptedFormats": "XPS, OXPS-filer",
|
||||||
|
"convertButton": "Konvertera till PDF"
|
||||||
|
},
|
||||||
|
"mobiToPdf": {
|
||||||
|
"name": "MOBI till PDF",
|
||||||
|
"subtitle": "Konvertera MOBI e-böcker till PDF-format. Stöder flera filer.",
|
||||||
|
"acceptedFormats": "MOBI-filer",
|
||||||
|
"convertButton": "Konvertera till PDF"
|
||||||
|
},
|
||||||
|
"epubToPdf": {
|
||||||
|
"name": "EPUB till PDF",
|
||||||
|
"subtitle": "Konvertera EPUB e-böcker till PDF-format. Stöder flera filer.",
|
||||||
|
"acceptedFormats": "EPUB-filer",
|
||||||
|
"convertButton": "Konvertera till PDF"
|
||||||
|
},
|
||||||
|
"fb2ToPdf": {
|
||||||
|
"name": "FB2 till PDF",
|
||||||
|
"subtitle": "Konvertera FictionBook (FB2) e-böcker till PDF-format. Stöder flera filer.",
|
||||||
|
"acceptedFormats": "FB2-filer",
|
||||||
|
"convertButton": "Konvertera till PDF"
|
||||||
|
},
|
||||||
|
"cbzToPdf": {
|
||||||
|
"name": "CBZ till PDF",
|
||||||
|
"subtitle": "Konvertera serietidshäften (CBZ/CBR) till PDF-format. Stöder flera filer.",
|
||||||
|
"acceptedFormats": "CBZ, CBR-filer",
|
||||||
|
"convertButton": "Konvertera till PDF"
|
||||||
|
},
|
||||||
|
"wpdToPdf": {
|
||||||
|
"name": "WPD till PDF",
|
||||||
|
"subtitle": "Konvertera WordPerfect-dokument (WPD) till PDF-format. Stöder flera filer.",
|
||||||
|
"acceptedFormats": "WPD-filer",
|
||||||
|
"convertButton": "Konvertera till PDF"
|
||||||
|
},
|
||||||
|
"wpsToPdf": {
|
||||||
|
"name": "WPS till PDF",
|
||||||
|
"subtitle": "Konvertera WPS Office-dokument till PDF-format. Stöder flera filer.",
|
||||||
|
"acceptedFormats": "WPS-filer",
|
||||||
|
"convertButton": "Konvertera till PDF"
|
||||||
|
},
|
||||||
|
"xmlToPdf": {
|
||||||
|
"name": "XML till PDF",
|
||||||
|
"subtitle": "Konvertera XML-dokument till PDF-format. Stöder flera filer.",
|
||||||
|
"acceptedFormats": "XML-filer",
|
||||||
|
"convertButton": "Konvertera till PDF"
|
||||||
|
},
|
||||||
|
"pagesToPdf": {
|
||||||
|
"name": "Pages till PDF",
|
||||||
|
"subtitle": "Konvertera Apple Pages-dokument till PDF-format. Stöder flera filer.",
|
||||||
|
"acceptedFormats": "Pages-filer",
|
||||||
|
"convertButton": "Konvertera till PDF"
|
||||||
|
},
|
||||||
|
"odgToPdf": {
|
||||||
|
"name": "ODG till PDF",
|
||||||
|
"subtitle": "Konvertera OpenDocument Graphics (ODG)-filer till PDF-format. Stöder flera filer.",
|
||||||
|
"acceptedFormats": "ODG-filer",
|
||||||
|
"convertButton": "Konvertera till PDF"
|
||||||
|
},
|
||||||
|
"odsToPdf": {
|
||||||
|
"name": "ODS till PDF",
|
||||||
|
"subtitle": "Konvertera OpenDocument Spreadsheet (ODS)-filer till PDF-format. Stöder flera filer.",
|
||||||
|
"acceptedFormats": "ODS-filer",
|
||||||
|
"convertButton": "Konvertera till PDF"
|
||||||
|
},
|
||||||
|
"odpToPdf": {
|
||||||
|
"name": "ODP till PDF",
|
||||||
|
"subtitle": "Konvertera OpenDocument Presentation (ODP)-filer till PDF-format. Stöder flera filer.",
|
||||||
|
"acceptedFormats": "ODP-filer",
|
||||||
|
"convertButton": "Konvertera till PDF"
|
||||||
|
},
|
||||||
|
"pubToPdf": {
|
||||||
|
"name": "PUB till PDF",
|
||||||
|
"subtitle": "Konvertera Microsoft Publisher (PUB)-filer till PDF-format. Stöder flera filer.",
|
||||||
|
"acceptedFormats": "PUB-filer",
|
||||||
|
"convertButton": "Konvertera till PDF"
|
||||||
|
},
|
||||||
|
"vsdToPdf": {
|
||||||
|
"name": "VSD till PDF",
|
||||||
|
"subtitle": "Konvertera Microsoft Visio (VSD, VSDX)-filer till PDF-format. Stöder flera filer.",
|
||||||
|
"acceptedFormats": "VSD, VSDX-filer",
|
||||||
|
"convertButton": "Konvertera till PDF"
|
||||||
|
},
|
||||||
|
"psdToPdf": {
|
||||||
|
"name": "PSD till PDF",
|
||||||
|
"subtitle": "Konvertera Adobe Photoshop (PSD)-filer till PDF-format. Stöder flera filer.",
|
||||||
|
"acceptedFormats": "PSD-filer",
|
||||||
|
"convertButton": "Konvertera till PDF"
|
||||||
|
},
|
||||||
|
"pdfToSvg": {
|
||||||
|
"name": "PDF till SVG",
|
||||||
|
"subtitle": "Konvertera varje sida i en PDF till en skalbar vektorgrafik (SVG) för perfekt kvalitet i valfri storlek."
|
||||||
|
},
|
||||||
|
"extractTables": {
|
||||||
|
"name": "Extrahera PDF-tabeller",
|
||||||
|
"subtitle": "Extrahera tabeller från PDF-filer och exportera som CSV, JSON eller Markdown."
|
||||||
|
},
|
||||||
|
"pdfToCsv": {
|
||||||
|
"name": "PDF till CSV",
|
||||||
|
"subtitle": "Extrahera tabeller från PDF och konvertera till CSV-format."
|
||||||
|
},
|
||||||
|
"pdfToExcel": {
|
||||||
|
"name": "PDF till Excel",
|
||||||
|
"subtitle": "Extrahera tabeller från PDF och konvertera till Excel (XLSX)-format."
|
||||||
|
},
|
||||||
|
"pdfToText": {
|
||||||
|
"name": "PDF till text",
|
||||||
|
"subtitle": "Extrahera text från PDF-filer och spara som vanlig text (.txt). Stöder flera filer.",
|
||||||
|
"note": "Detta verktyg fungerar ENDAST med digitalt skapade PDF:er. För scannade dokument eller bildbaserade PDF:er, använd vårt OCR PDF-verktyg istället.",
|
||||||
|
"convertButton": "Extrahera text"
|
||||||
|
},
|
||||||
|
"digitalSignPdf": {
|
||||||
|
"name": "Digital signatur PDF",
|
||||||
|
"pageTitle": "Digital signatur PDF - Lägg till kryptografisk signatur | BentoPDF",
|
||||||
|
"subtitle": "Lägg till en kryptografisk digital signatur på din PDF med X.509-certifikat. Stöder PKCS#12 (.pfx, .p12) och PEM-format. Din privata nyckel lämnar aldrig din webbläsare.",
|
||||||
|
"certificateSection": "Certifikat",
|
||||||
|
"uploadCert": "Ladda upp certifikat (.pfx, .p12)",
|
||||||
|
"certPassword": "Certifikatlösenord",
|
||||||
|
"certPasswordPlaceholder": "Ange certifikatlösenord",
|
||||||
|
"certInfo": "Certifikatinformation",
|
||||||
|
"certSubject": "Ämne",
|
||||||
|
"certIssuer": "Utfärdare",
|
||||||
|
"certValidity": "Giltig",
|
||||||
|
"signatureDetails": "Signaturdetaljer (Valfritt)",
|
||||||
|
"reason": "Anledning",
|
||||||
|
"reasonPlaceholder": "t.ex. Jag godkänner detta dokument",
|
||||||
|
"location": "Plats",
|
||||||
|
"locationPlaceholder": "t.ex. Stockholm, Sverige",
|
||||||
|
"contactInfo": "Kontaktinformation",
|
||||||
|
"contactPlaceholder": "t.ex. [email protected]",
|
||||||
|
"applySignature": "Applicera digital signatur",
|
||||||
|
"successMessage": "PDF signerad framgångsrikt! Signaturen kan verifieras i vilken PDF-läsare som helst."
|
||||||
|
},
|
||||||
|
"validateSignaturePdf": {
|
||||||
|
"name": "Validera PDF-signatur",
|
||||||
|
"pageTitle": "Validera PDF-signatur - Verifiera digitala signaturer | BentoPDF",
|
||||||
|
"subtitle": "Verifiera digitala signaturer i dina PDF-filer. Kontrollera certifikatgiltighet, visa signerardetaljer och bekräfta dokumentintegritet. All bearbetning sker i din webbläsare."
|
||||||
|
},
|
||||||
|
"emailToPdf": {
|
||||||
|
"name": "E-post till PDF",
|
||||||
|
"subtitle": "Konvertera e-postfiler (EML, MSG) till PDF-format. Stöder Outlook-exporter och standard e-postformat.",
|
||||||
|
"acceptedFormats": "EML, MSG-filer",
|
||||||
|
"convertButton": "Konvertera till PDF"
|
||||||
|
},
|
||||||
|
"fontToOutline": {
|
||||||
|
"name": "Typsnitt till kontur",
|
||||||
|
"subtitle": "Konvertera alla typsnitt till vektorkonturer för konsekvent rendering på alla enheter."
|
||||||
|
},
|
||||||
|
"deskewPdf": {
|
||||||
|
"name": "Räta upp PDF",
|
||||||
|
"subtitle": "Automatiskt räta upp snedvinklade scannade sidor med OpenCV."
|
||||||
|
},
|
||||||
|
"pdfToWord": {
|
||||||
|
"name": "PDF till Word",
|
||||||
|
"subtitle": "Konvertera PDF-filer till redigerbara Word-dokument."
|
||||||
|
},
|
||||||
|
"extractImages": {
|
||||||
|
"name": "Extrahera bilder",
|
||||||
|
"subtitle": "Extrahera alla inbäddade bilder från dina PDF-filer."
|
||||||
|
},
|
||||||
|
"pdfToMarkdown": {
|
||||||
|
"name": "PDF till Markdown",
|
||||||
|
"subtitle": "Konvertera PDF-text och tabeller till Markdown-format."
|
||||||
|
},
|
||||||
|
"preparePdfForAi": {
|
||||||
|
"name": "Förbered PDF för AI",
|
||||||
|
"subtitle": "Extrahera PDF-innehåll som LlamaIndex JSON för RAG/LLM-pipelines."
|
||||||
|
},
|
||||||
|
"pdfOcg": {
|
||||||
|
"name": "PDF OCG",
|
||||||
|
"subtitle": "Visa, växla, lägg till och ta bort OCG-lager i din PDF."
|
||||||
|
},
|
||||||
|
"pdfToPdfa": {
|
||||||
|
"name": "PDF till PDF/A",
|
||||||
|
"subtitle": "Konvertera PDF till PDF/A för långtidsarkivering."
|
||||||
|
},
|
||||||
|
"rasterizePdf": {
|
||||||
|
"name": "Rasterisera PDF",
|
||||||
|
"subtitle": "Konvertera PDF till bildbaserad PDF. Platta till lager och ta bort valbar text."
|
||||||
|
},
|
||||||
|
"pdfWorkflow": {
|
||||||
|
"name": "PDF-arbetsflödesbyggare",
|
||||||
|
"subtitle": "Bygg anpassade PDF-bearbetningspipelines med en visuell nodredigerare.",
|
||||||
|
"nodes": "Noder",
|
||||||
|
"searchNodes": "Sök noder...",
|
||||||
|
"run": "Kör",
|
||||||
|
"clear": "Rensa",
|
||||||
|
"save": "Spara",
|
||||||
|
"load": "Ladda",
|
||||||
|
"export": "Exportera",
|
||||||
|
"import": "Importera",
|
||||||
|
"ready": "Redo",
|
||||||
|
"settings": "Inställningar",
|
||||||
|
"processing": "Bearbetar...",
|
||||||
|
"saveTemplate": "Spara mall",
|
||||||
|
"templateName": "Mallnamn",
|
||||||
|
"templatePlaceholder": "t.ex. Faktura-arbetsflöde",
|
||||||
|
"cancel": "Avbryt",
|
||||||
|
"loadTemplate": "Ladda mall",
|
||||||
|
"noTemplates": "Inga sparade mallar ännu.",
|
||||||
|
"ok": "OK",
|
||||||
|
"workflowCompleted": "Arbetsflöde klart",
|
||||||
|
"errorDuringExecution": "Fel under körning",
|
||||||
|
"addNodeError": "Lägg till minst en nod för att köra arbetsflödet.",
|
||||||
|
"needInputOutput": "Ditt arbetsflöde behöver minst en inmatningsnod och en utmatningsnod för att köra.",
|
||||||
|
"enterName": "Vänligen ange ett namn.",
|
||||||
|
"templateExists": "En mall med detta namn finns redan.",
|
||||||
|
"templateSaved": "Mallen \"{{name}}\" sparad.",
|
||||||
|
"templateLoaded": "Mallen \"{{name}}\" laddad.",
|
||||||
|
"failedLoadTemplate": "Det gick inte att ladda mallen.",
|
||||||
|
"noSettings": "Inga konfigurerbara inställningar för denna nod.",
|
||||||
|
"advancedSettings": "Avancerade inställningar"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -22,7 +22,29 @@
|
|||||||
},
|
},
|
||||||
"compressPdf": {
|
"compressPdf": {
|
||||||
"name": "PDF Sıkıştır",
|
"name": "PDF Sıkıştır",
|
||||||
"subtitle": "PDF dosya boyutunu küçültün."
|
"subtitle": "PDF dosya boyutunu küçültün.",
|
||||||
|
"algorithmLabel": "Sıkıştırma Algoritması",
|
||||||
|
"condense": "Condense (Önerilen)",
|
||||||
|
"photon": "Photon (Fotoğraf Ağırlıklı PDF'ler İçin)",
|
||||||
|
"condenseInfo": "Condense gelişmiş sıkıştırma kullanır: gereksiz verileri kaldırır, görselleri optimize eder, fontları küçültür. Çoğu PDF için idealdir.",
|
||||||
|
"photonInfo": "Photon sayfaları görsellere dönüştürür. Fotoğraf ağırlıklı/taranmış PDF'ler için kullanın.",
|
||||||
|
"photonWarning": "Uyarı: Metin seçilemez hale gelecek ve bağlantılar çalışmayacak.",
|
||||||
|
"levelLabel": "Sıkıştırma Seviyesi",
|
||||||
|
"light": "Hafif (Kaliteyi Koru)",
|
||||||
|
"balanced": "Dengeli (Önerilen)",
|
||||||
|
"aggressive": "Agresif (Daha Küçük Dosyalar)",
|
||||||
|
"extreme": "Aşırı (Maksimum Sıkıştırma)",
|
||||||
|
"grayscale": "Gri Tonlamaya Dönüştür",
|
||||||
|
"grayscaleHint": "Renk bilgisini kaldırarak dosya boyutunu küçültür",
|
||||||
|
"customSettings": "Özel Ayarlar",
|
||||||
|
"customSettingsHint": "Sıkıştırma parametrelerini ince ayarlayın:",
|
||||||
|
"outputQuality": "Çıktı Kalitesi",
|
||||||
|
"resizeImagesTo": "Görselleri Şu Boyuta Dönüştür",
|
||||||
|
"onlyProcessAbove": "Yalnızca Şunun Üzerini İşle",
|
||||||
|
"removeMetadata": "Meta verileri kaldır",
|
||||||
|
"subsetFonts": "Font alt kümesi oluştur (kullanılmayan glifleri kaldır)",
|
||||||
|
"removeThumbnails": "Gömülü küçük resimleri kaldır",
|
||||||
|
"compressButton": "PDF'yi Sıkıştır"
|
||||||
},
|
},
|
||||||
"pdfEditor": {
|
"pdfEditor": {
|
||||||
"name": "PDF Düzenleyici",
|
"name": "PDF Düzenleyici",
|
||||||
@@ -70,7 +92,8 @@
|
|||||||
},
|
},
|
||||||
"addWatermark": {
|
"addWatermark": {
|
||||||
"name": "Filigran Ekle",
|
"name": "Filigran Ekle",
|
||||||
"subtitle": "PDF sayfalarınızın üzerine metin veya görsel damgası ekleyin."
|
"subtitle": "PDF sayfalarınızın üzerine metin veya görsel damgası ekleyin.",
|
||||||
|
"applyToAllPages": "Tüm sayfalara uygula"
|
||||||
},
|
},
|
||||||
"headerFooter": {
|
"headerFooter": {
|
||||||
"name": "Üst Bilgi & Alt Bilgi",
|
"name": "Üst Bilgi & Alt Bilgi",
|
||||||
@@ -140,7 +163,8 @@
|
|||||||
},
|
},
|
||||||
"removeBlankPages": {
|
"removeBlankPages": {
|
||||||
"name": "Boş Sayfaları Kaldır",
|
"name": "Boş Sayfaları Kaldır",
|
||||||
"subtitle": "Boş sayfaları otomatik olarak tespit edin ve silin."
|
"subtitle": "Boş sayfaları otomatik olarak tespit edin ve silin.",
|
||||||
|
"sensitivityHint": "Yüksek = daha katı, yalnızca tamamen boş sayfalar. Düşük = biraz içerik olan sayfalara izin verir."
|
||||||
},
|
},
|
||||||
"imageToPdf": {
|
"imageToPdf": {
|
||||||
"name": "Görselden PDF'ye",
|
"name": "Görselden PDF'ye",
|
||||||
|
|||||||
@@ -92,7 +92,8 @@
|
|||||||
},
|
},
|
||||||
"addWatermark": {
|
"addWatermark": {
|
||||||
"name": "Thêm Watermark",
|
"name": "Thêm Watermark",
|
||||||
"subtitle": "Đóng dấu văn bản hoặc hình ảnh lên các trang PDF của bạn."
|
"subtitle": "Đóng dấu văn bản hoặc hình ảnh lên các trang PDF của bạn.",
|
||||||
|
"applyToAllPages": "Áp dụng cho tất cả các trang"
|
||||||
},
|
},
|
||||||
"headerFooter": {
|
"headerFooter": {
|
||||||
"name": "Đầu trang & Chân trang",
|
"name": "Đầu trang & Chân trang",
|
||||||
@@ -162,7 +163,8 @@
|
|||||||
},
|
},
|
||||||
"removeBlankPages": {
|
"removeBlankPages": {
|
||||||
"name": "Xóa trang trống",
|
"name": "Xóa trang trống",
|
||||||
"subtitle": "Tự động phát hiện và xóa trang trống."
|
"subtitle": "Tự động phát hiện và xóa trang trống.",
|
||||||
|
"sensitivityHint": "Cao hơn = nghiêm ngặt hơn, chỉ các trang hoàn toàn trống. Thấp hơn = cho phép trang có một số nội dung."
|
||||||
},
|
},
|
||||||
"imageToPdf": {
|
"imageToPdf": {
|
||||||
"name": "Hình ảnh sang PDF",
|
"name": "Hình ảnh sang PDF",
|
||||||
|
|||||||
@@ -22,7 +22,29 @@
|
|||||||
},
|
},
|
||||||
"compressPdf": {
|
"compressPdf": {
|
||||||
"name": "壓縮 PDF",
|
"name": "壓縮 PDF",
|
||||||
"subtitle": "降低你的 PDF 檔案大小。"
|
"subtitle": "降低你的 PDF 檔案大小。",
|
||||||
|
"algorithmLabel": "壓縮演算法",
|
||||||
|
"condense": "Condense(推薦)",
|
||||||
|
"photon": "Photon(適用於圖片較多的 PDF)",
|
||||||
|
"condenseInfo": "Condense 使用高級壓縮:移除冗餘資料、優化圖片、精簡字型。適用於大多數 PDF。",
|
||||||
|
"photonInfo": "Photon 將頁面轉換為圖片。適用於圖片較多/掃描的 PDF。",
|
||||||
|
"photonWarning": "警告:文字將無法選取,連結將失效。",
|
||||||
|
"levelLabel": "壓縮等級",
|
||||||
|
"light": "輕度(保持品質)",
|
||||||
|
"balanced": "平衡(推薦)",
|
||||||
|
"aggressive": "積極(更小檔案)",
|
||||||
|
"extreme": "極限(最大壓縮)",
|
||||||
|
"grayscale": "轉換為灰階",
|
||||||
|
"grayscaleHint": "透過移除色彩資訊來縮小檔案大小",
|
||||||
|
"customSettings": "自訂設定",
|
||||||
|
"customSettingsHint": "微調壓縮參數:",
|
||||||
|
"outputQuality": "輸出品質",
|
||||||
|
"resizeImagesTo": "調整圖片至",
|
||||||
|
"onlyProcessAbove": "僅處理高於",
|
||||||
|
"removeMetadata": "移除中繼資料",
|
||||||
|
"subsetFonts": "精簡字型(移除未使用的字符)",
|
||||||
|
"removeThumbnails": "移除嵌入的縮圖",
|
||||||
|
"compressButton": "壓縮 PDF"
|
||||||
},
|
},
|
||||||
"pdfEditor": {
|
"pdfEditor": {
|
||||||
"name": "PDF 編輯器",
|
"name": "PDF 編輯器",
|
||||||
@@ -70,7 +92,8 @@
|
|||||||
},
|
},
|
||||||
"addWatermark": {
|
"addWatermark": {
|
||||||
"name": "添加浮水印",
|
"name": "添加浮水印",
|
||||||
"subtitle": "在你的 PDF 頁面上壓印文字或圖片。"
|
"subtitle": "在你的 PDF 頁面上壓印文字或圖片。",
|
||||||
|
"applyToAllPages": "套用至所有頁面"
|
||||||
},
|
},
|
||||||
"headerFooter": {
|
"headerFooter": {
|
||||||
"name": "頁首與頁尾",
|
"name": "頁首與頁尾",
|
||||||
@@ -140,7 +163,8 @@
|
|||||||
},
|
},
|
||||||
"removeBlankPages": {
|
"removeBlankPages": {
|
||||||
"name": "移除空白頁面",
|
"name": "移除空白頁面",
|
||||||
"subtitle": "自動偵測並刪除空白頁面。"
|
"subtitle": "自動偵測並刪除空白頁面。",
|
||||||
|
"sensitivityHint": "越高 = 越嚴格,僅偵測純空白頁面。越低 = 允許包含少量內容的頁面。"
|
||||||
},
|
},
|
||||||
"imageToPdf": {
|
"imageToPdf": {
|
||||||
"name": "圖片轉 PDF",
|
"name": "圖片轉 PDF",
|
||||||
|
|||||||
@@ -92,7 +92,8 @@
|
|||||||
},
|
},
|
||||||
"addWatermark": {
|
"addWatermark": {
|
||||||
"name": "添加水印",
|
"name": "添加水印",
|
||||||
"subtitle": "在您的 PDF 页面上添加文字或图片水印。"
|
"subtitle": "在您的 PDF 页面上添加文字或图片水印。",
|
||||||
|
"applyToAllPages": "应用到所有页面"
|
||||||
},
|
},
|
||||||
"headerFooter": {
|
"headerFooter": {
|
||||||
"name": "页眉和页脚",
|
"name": "页眉和页脚",
|
||||||
@@ -143,7 +144,10 @@
|
|||||||
},
|
},
|
||||||
"addStamps": {
|
"addStamps": {
|
||||||
"name": "添加印章",
|
"name": "添加印章",
|
||||||
"subtitle": "使用注释工具栏向您的 PDF 添加图片印章。"
|
"subtitle": "使用注释工具栏向您的 PDF 添加图片印章。",
|
||||||
|
"usernameLabel": "印章用户名",
|
||||||
|
"usernamePlaceholder": "输入您的姓名(用于印章)",
|
||||||
|
"usernameHint": "此名称将显示在您创建的印章上。"
|
||||||
},
|
},
|
||||||
"removeAnnotations": {
|
"removeAnnotations": {
|
||||||
"name": "移除注释",
|
"name": "移除注释",
|
||||||
@@ -159,7 +163,8 @@
|
|||||||
},
|
},
|
||||||
"removeBlankPages": {
|
"removeBlankPages": {
|
||||||
"name": "移除空白页",
|
"name": "移除空白页",
|
||||||
"subtitle": "自动检测并删除空白页。"
|
"subtitle": "自动检测并删除空白页。",
|
||||||
|
"sensitivityHint": "越高 = 越严格,仅检测纯空白页。越低 = 允许包含少量内容的页面。"
|
||||||
},
|
},
|
||||||
"imageToPdf": {
|
"imageToPdf": {
|
||||||
"name": "图片转 PDF",
|
"name": "图片转 PDF",
|
||||||
|
|||||||
@@ -269,4 +269,7 @@ async function generateI18nPages() {
|
|||||||
console.log('✅ i18n pages generated successfully!');
|
console.log('✅ i18n pages generated successfully!');
|
||||||
}
|
}
|
||||||
|
|
||||||
generateI18nPages().catch(console.error);
|
generateI18nPages().catch((err) => {
|
||||||
|
console.error('❌ i18n page generation failed:', err);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
|
|||||||
@@ -327,6 +327,38 @@
|
|||||||
"created_at": "2026-02-21T00:26:29Z",
|
"created_at": "2026-02-21T00:26:29Z",
|
||||||
"repoId": 1074785178,
|
"repoId": 1074785178,
|
||||||
"pullRequestNo": 501
|
"pullRequestNo": 501
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "hagibr",
|
||||||
|
"id": 23699887,
|
||||||
|
"comment_id": 3960964454,
|
||||||
|
"created_at": "2026-02-25T17:50:30Z",
|
||||||
|
"repoId": 1074785178,
|
||||||
|
"pullRequestNo": 516
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "maxbengtzen",
|
||||||
|
"id": 149163078,
|
||||||
|
"comment_id": 3972875117,
|
||||||
|
"created_at": "2026-02-27T13:07:34Z",
|
||||||
|
"repoId": 1074785178,
|
||||||
|
"pullRequestNo": 529
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Olivetti",
|
||||||
|
"id": 1502093,
|
||||||
|
"comment_id": 3984507146,
|
||||||
|
"created_at": "2026-03-02T13:46:02Z",
|
||||||
|
"repoId": 1074785178,
|
||||||
|
"pullRequestNo": 531
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "InstaZDLL",
|
||||||
|
"id": 72951793,
|
||||||
|
"comment_id": 3984770306,
|
||||||
|
"created_at": "2026-03-02T14:33:11Z",
|
||||||
|
"repoId": 1074785178,
|
||||||
|
"pullRequestNo": 538
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -332,6 +332,38 @@
|
|||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
class="flex items-center justify-between p-4 bg-gray-900 rounded-lg border border-gray-700"
|
||||||
|
>
|
||||||
|
<div class="flex-1">
|
||||||
|
<label
|
||||||
|
for="compact-mode-toggle"
|
||||||
|
class="text-sm font-medium text-gray-200 cursor-pointer"
|
||||||
|
data-i18n="settings.compactMode"
|
||||||
|
>
|
||||||
|
Compact Mode
|
||||||
|
</label>
|
||||||
|
<p
|
||||||
|
class="text-xs text-gray-400 mt-1"
|
||||||
|
data-i18n="settings.compactModeDescription"
|
||||||
|
>
|
||||||
|
Display tools as a compact list instead of cards
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<label
|
||||||
|
class="relative inline-flex items-center cursor-pointer ml-4"
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
id="compact-mode-toggle"
|
||||||
|
class="sr-only peer"
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
class="w-11 h-6 bg-gray-700 peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-indigo-800 rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-indigo-600"
|
||||||
|
></div>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
<a
|
<a
|
||||||
href="wasm-settings.html"
|
href="wasm-settings.html"
|
||||||
class="flex items-center justify-between p-4 bg-gray-900 rounded-lg border border-gray-700 hover:bg-gray-800 hover:border-indigo-500/50 transition-all group"
|
class="flex items-center justify-between p-4 bg-gray-900 rounded-lg border border-gray-700 hover:bg-gray-800 hover:border-indigo-500/50 transition-all group"
|
||||||
|
|||||||
@@ -701,6 +701,33 @@ button:disabled,
|
|||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Color picker */
|
||||||
|
input[type='color'] {
|
||||||
|
width: 2.5rem;
|
||||||
|
height: 2.5rem;
|
||||||
|
padding: 0;
|
||||||
|
border: 2px solid #4b5563;
|
||||||
|
border-radius: 9999px;
|
||||||
|
background: transparent;
|
||||||
|
cursor: pointer;
|
||||||
|
appearance: none;
|
||||||
|
-webkit-appearance: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
input[type='color']::-webkit-color-swatch-wrapper {
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
input[type='color']::-webkit-color-swatch {
|
||||||
|
border: none;
|
||||||
|
border-radius: 9999px;
|
||||||
|
}
|
||||||
|
|
||||||
|
input[type='color']::-moz-color-swatch {
|
||||||
|
border: none;
|
||||||
|
border-radius: 9999px;
|
||||||
|
}
|
||||||
|
|
||||||
/* Hide spin buttons for number inputs */
|
/* Hide spin buttons for number inputs */
|
||||||
input[type='number'] {
|
input[type='number'] {
|
||||||
appearance: none;
|
appearance: none;
|
||||||
|
|||||||
+14
-1
@@ -1,5 +1,5 @@
|
|||||||
// This file centralizes the definition of all available tools, organized by category.
|
// This file centralizes the definition of all available tools, organized by category.
|
||||||
export const categories = [
|
const baseCategories = [
|
||||||
{
|
{
|
||||||
name: 'Popular Tools',
|
name: 'Popular Tools',
|
||||||
tools: [
|
tools: [
|
||||||
@@ -788,3 +788,16 @@ export const categories = [
|
|||||||
],
|
],
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
const getToolIdFromHref = (href: string): string => {
|
||||||
|
const match = href.match(/\/([^/]+)\.html$/);
|
||||||
|
return match?.[1] ?? href;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const categories = baseCategories.map((category) => ({
|
||||||
|
...category,
|
||||||
|
tools: category.tools.map((tool) => ({
|
||||||
|
...tool,
|
||||||
|
id: getToolIdFromHref(tool.href),
|
||||||
|
})),
|
||||||
|
}));
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
export const PACKAGE_VERSIONS = {
|
export const PACKAGE_VERSIONS = {
|
||||||
ghostscript: '0.1.1',
|
ghostscript: '0.1.1',
|
||||||
pymupdf: '0.11.14',
|
pymupdf: '0.11.16',
|
||||||
} as const;
|
} as const;
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ export const supportedLanguages = [
|
|||||||
'pt',
|
'pt',
|
||||||
'nl',
|
'nl',
|
||||||
'da',
|
'da',
|
||||||
|
'sv',
|
||||||
] as const;
|
] as const;
|
||||||
export type SupportedLanguage = (typeof supportedLanguages)[number];
|
export type SupportedLanguage = (typeof supportedLanguages)[number];
|
||||||
|
|
||||||
@@ -37,6 +38,7 @@ export const languageNames: Record<SupportedLanguage, string> = {
|
|||||||
pt: 'Português',
|
pt: 'Português',
|
||||||
nl: 'Nederlands',
|
nl: 'Nederlands',
|
||||||
da: 'Dansk',
|
da: 'Dansk',
|
||||||
|
sv: 'Svenska',
|
||||||
};
|
};
|
||||||
|
|
||||||
export const getLanguageFromUrl = (): SupportedLanguage => {
|
export const getLanguageFromUrl = (): SupportedLanguage => {
|
||||||
|
|||||||
+872
-129
File diff suppressed because it is too large
Load Diff
@@ -253,7 +253,7 @@ placeholder="${field.placeholder || ''}" />
|
|||||||
)
|
)
|
||||||
.join('')}
|
.join('')}
|
||||||
</select>
|
</select>
|
||||||
${field.name === 'color' ? '<input type="color" id="modal-color-picker" class="hidden w-full h-10 mt-2 rounded cursor-pointer border border-gray-300" value="#000000" />' : ''}
|
${field.name === 'color' ? '<input type="color" id="modal-color-picker" class="hidden mt-2" value="#000000" />' : ''}
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
} else if (field.type === 'destination') {
|
} else if (field.type === 'destination') {
|
||||||
|
|||||||
+284
-162
@@ -1,236 +1,358 @@
|
|||||||
import { showAlert } from '../ui.js';
|
import { showAlert } from '../ui.js';
|
||||||
import { downloadFile, formatBytes, initializeQpdf, readFileAsArrayBuffer } from '../utils/helpers.js';
|
import {
|
||||||
|
downloadFile,
|
||||||
|
formatBytes,
|
||||||
|
initializeQpdf,
|
||||||
|
readFileAsArrayBuffer,
|
||||||
|
} from '../utils/helpers.js';
|
||||||
import { icons, createIcons } from 'lucide';
|
import { icons, createIcons } from 'lucide';
|
||||||
|
import JSZip from 'jszip';
|
||||||
import { DecryptPdfState } from '@/types';
|
import { DecryptPdfState } from '@/types';
|
||||||
|
|
||||||
const pageState: DecryptPdfState = {
|
const pageState: DecryptPdfState = {
|
||||||
file: null,
|
files: [],
|
||||||
};
|
};
|
||||||
|
|
||||||
function resetState() {
|
function resetState() {
|
||||||
pageState.file = null;
|
pageState.files = [];
|
||||||
|
|
||||||
const fileDisplayArea = document.getElementById('file-display-area');
|
const fileDisplayArea = document.getElementById('file-display-area');
|
||||||
if (fileDisplayArea) fileDisplayArea.innerHTML = '';
|
if (fileDisplayArea) fileDisplayArea.innerHTML = '';
|
||||||
|
|
||||||
const toolOptions = document.getElementById('tool-options');
|
const toolOptions = document.getElementById('tool-options');
|
||||||
if (toolOptions) toolOptions.classList.add('hidden');
|
if (toolOptions) toolOptions.classList.add('hidden');
|
||||||
|
|
||||||
const fileInput = document.getElementById('file-input') as HTMLInputElement;
|
const fileControls = document.getElementById('file-controls');
|
||||||
if (fileInput) fileInput.value = '';
|
if (fileControls) fileControls.classList.add('hidden');
|
||||||
|
|
||||||
const passwordInput = document.getElementById('password-input') as HTMLInputElement;
|
const fileInput = document.getElementById('file-input') as HTMLInputElement;
|
||||||
if (passwordInput) passwordInput.value = '';
|
if (fileInput) fileInput.value = '';
|
||||||
|
|
||||||
|
const passwordInput = document.getElementById(
|
||||||
|
'password-input'
|
||||||
|
) as HTMLInputElement;
|
||||||
|
if (passwordInput) passwordInput.value = '';
|
||||||
}
|
}
|
||||||
|
|
||||||
async function updateUI() {
|
async function updateUI() {
|
||||||
const fileDisplayArea = document.getElementById('file-display-area');
|
const fileDisplayArea = document.getElementById('file-display-area');
|
||||||
const toolOptions = document.getElementById('tool-options');
|
const toolOptions = document.getElementById('tool-options');
|
||||||
|
const fileControls = document.getElementById('file-controls');
|
||||||
|
|
||||||
if (!fileDisplayArea) return;
|
if (!fileDisplayArea) return;
|
||||||
|
|
||||||
fileDisplayArea.innerHTML = '';
|
fileDisplayArea.innerHTML = '';
|
||||||
|
|
||||||
if (pageState.file) {
|
if (pageState.files.length > 0) {
|
||||||
const fileDiv = document.createElement('div');
|
pageState.files.forEach((file, index) => {
|
||||||
fileDiv.className = 'flex items-center justify-between bg-gray-700 p-3 rounded-lg text-sm';
|
const fileDiv = document.createElement('div');
|
||||||
|
fileDiv.className =
|
||||||
|
'flex items-center justify-between bg-gray-700 p-3 rounded-lg text-sm';
|
||||||
|
|
||||||
const infoContainer = document.createElement('div');
|
const infoContainer = document.createElement('div');
|
||||||
infoContainer.className = 'flex flex-col overflow-hidden';
|
infoContainer.className = 'flex flex-col overflow-hidden';
|
||||||
|
|
||||||
const nameSpan = document.createElement('div');
|
const nameSpan = document.createElement('div');
|
||||||
nameSpan.className = 'truncate font-medium text-gray-200 text-sm mb-1';
|
nameSpan.className = 'truncate font-medium text-gray-200 text-sm mb-1';
|
||||||
nameSpan.textContent = pageState.file.name;
|
nameSpan.textContent = file.name;
|
||||||
|
|
||||||
const metaSpan = document.createElement('div');
|
const metaSpan = document.createElement('div');
|
||||||
metaSpan.className = 'text-xs text-gray-400';
|
metaSpan.className = 'text-xs text-gray-400';
|
||||||
metaSpan.textContent = formatBytes(pageState.file.size);
|
metaSpan.textContent = formatBytes(file.size);
|
||||||
|
|
||||||
infoContainer.append(nameSpan, metaSpan);
|
infoContainer.append(nameSpan, metaSpan);
|
||||||
|
|
||||||
const removeBtn = document.createElement('button');
|
const removeBtn = document.createElement('button');
|
||||||
removeBtn.className = 'ml-4 text-red-400 hover:text-red-300 flex-shrink-0';
|
removeBtn.className =
|
||||||
removeBtn.innerHTML = '<i data-lucide="trash-2" class="w-4 h-4"></i>';
|
'ml-4 text-red-400 hover:text-red-300 flex-shrink-0';
|
||||||
removeBtn.onclick = function () {
|
removeBtn.innerHTML = '<i data-lucide="trash-2" class="w-4 h-4"></i>';
|
||||||
resetState();
|
removeBtn.onclick = function () {
|
||||||
};
|
pageState.files.splice(index, 1);
|
||||||
|
updateUI();
|
||||||
|
};
|
||||||
|
|
||||||
fileDiv.append(infoContainer, removeBtn);
|
fileDiv.append(infoContainer, removeBtn);
|
||||||
fileDisplayArea.appendChild(fileDiv);
|
fileDisplayArea.appendChild(fileDiv);
|
||||||
createIcons({ icons });
|
});
|
||||||
|
|
||||||
if (toolOptions) toolOptions.classList.remove('hidden');
|
createIcons({ icons });
|
||||||
} else {
|
|
||||||
if (toolOptions) toolOptions.classList.add('hidden');
|
if (toolOptions) toolOptions.classList.remove('hidden');
|
||||||
}
|
if (fileControls) fileControls.classList.remove('hidden');
|
||||||
|
} else {
|
||||||
|
if (toolOptions) toolOptions.classList.add('hidden');
|
||||||
|
if (fileControls) fileControls.classList.add('hidden');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleFileSelect(files: FileList | null) {
|
function handleFileSelect(files: FileList | null) {
|
||||||
if (files && files.length > 0) {
|
if (files && files.length > 0) {
|
||||||
const file = files[0];
|
const pdfFiles = Array.from(files).filter(
|
||||||
if (file.type === 'application/pdf' || file.name.toLowerCase().endsWith('.pdf')) {
|
(f) =>
|
||||||
pageState.file = file;
|
f.type === 'application/pdf' || f.name.toLowerCase().endsWith('.pdf')
|
||||||
updateUI();
|
);
|
||||||
}
|
if (pdfFiles.length > 0) {
|
||||||
|
pageState.files.push(...pdfFiles);
|
||||||
|
updateUI();
|
||||||
}
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function decryptPdf() {
|
async function decryptPdf() {
|
||||||
if (!pageState.file) {
|
if (pageState.files.length === 0) {
|
||||||
showAlert('No File', 'Please upload a PDF file first.');
|
showAlert('No File', 'Please upload at least one PDF file.');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const password = (document.getElementById('password-input') as HTMLInputElement)?.value;
|
const password = (
|
||||||
|
document.getElementById('password-input') as HTMLInputElement
|
||||||
|
)?.value;
|
||||||
|
|
||||||
if (!password) {
|
if (!password) {
|
||||||
showAlert('Input Required', 'Please enter the PDF password.');
|
showAlert('Input Required', 'Please enter the PDF password.');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const inputPath = '/input.pdf';
|
const loaderModal = document.getElementById('loader-modal');
|
||||||
const outputPath = '/output.pdf';
|
const loaderText = document.getElementById('loader-text');
|
||||||
let qpdf: any;
|
let qpdf: any;
|
||||||
|
|
||||||
const loaderModal = document.getElementById('loader-modal');
|
try {
|
||||||
const loaderText = document.getElementById('loader-text');
|
if (loaderModal) loaderModal.classList.remove('hidden');
|
||||||
|
if (loaderText) loaderText.textContent = 'Initializing decryption...';
|
||||||
|
|
||||||
try {
|
qpdf = await initializeQpdf();
|
||||||
if (loaderModal) loaderModal.classList.remove('hidden');
|
|
||||||
if (loaderText) loaderText.textContent = 'Initializing decryption...';
|
|
||||||
|
|
||||||
qpdf = await initializeQpdf();
|
if (pageState.files.length === 1) {
|
||||||
|
// Single file: decrypt and download directly
|
||||||
|
const file = pageState.files[0];
|
||||||
|
const inputPath = '/input.pdf';
|
||||||
|
const outputPath = '/output.pdf';
|
||||||
|
|
||||||
|
try {
|
||||||
if (loaderText) loaderText.textContent = 'Reading encrypted PDF...';
|
if (loaderText) loaderText.textContent = 'Reading encrypted PDF...';
|
||||||
const fileBuffer = await readFileAsArrayBuffer(pageState.file);
|
const fileBuffer = await readFileAsArrayBuffer(file);
|
||||||
const uint8Array = new Uint8Array(fileBuffer as ArrayBuffer);
|
const uint8Array = new Uint8Array(fileBuffer as ArrayBuffer);
|
||||||
|
|
||||||
qpdf.FS.writeFile(inputPath, uint8Array);
|
qpdf.FS.writeFile(inputPath, uint8Array);
|
||||||
|
|
||||||
if (loaderText) loaderText.textContent = 'Decrypting PDF...';
|
if (loaderText) loaderText.textContent = 'Decrypting PDF...';
|
||||||
|
const args = [
|
||||||
const args = [inputPath, '--password=' + password, '--decrypt', outputPath];
|
inputPath,
|
||||||
|
'--password=' + password,
|
||||||
|
'--decrypt',
|
||||||
|
outputPath,
|
||||||
|
];
|
||||||
|
|
||||||
try {
|
try {
|
||||||
qpdf.callMain(args);
|
qpdf.callMain(args);
|
||||||
} catch (qpdfError: any) {
|
} catch (qpdfError: any) {
|
||||||
console.error('qpdf execution error:', qpdfError);
|
if (
|
||||||
|
qpdfError.message?.includes('invalid password') ||
|
||||||
if (
|
qpdfError.message?.includes('password')
|
||||||
qpdfError.message?.includes('invalid password') ||
|
) {
|
||||||
qpdfError.message?.includes('password')
|
throw new Error('INVALID_PASSWORD');
|
||||||
) {
|
}
|
||||||
throw new Error('INVALID_PASSWORD');
|
throw qpdfError;
|
||||||
}
|
|
||||||
throw qpdfError;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (loaderText) loaderText.textContent = 'Preparing download...';
|
if (loaderText) loaderText.textContent = 'Preparing download...';
|
||||||
const outputFile = qpdf.FS.readFile(outputPath, { encoding: 'binary' });
|
const outputFile = qpdf.FS.readFile(outputPath, { encoding: 'binary' });
|
||||||
|
|
||||||
if (outputFile.length === 0) {
|
if (outputFile.length === 0) {
|
||||||
throw new Error('Decryption resulted in an empty file.');
|
throw new Error('Decryption resulted in an empty file.');
|
||||||
}
|
}
|
||||||
|
|
||||||
const blob = new Blob([outputFile], { type: 'application/pdf' });
|
const blob = new Blob([new Uint8Array(outputFile)], {
|
||||||
downloadFile(blob, `unlocked-${pageState.file.name}`);
|
type: 'application/pdf',
|
||||||
|
});
|
||||||
|
downloadFile(blob, `unlocked-${file.name}`);
|
||||||
|
|
||||||
if (loaderModal) loaderModal.classList.add('hidden');
|
if (loaderModal) loaderModal.classList.add('hidden');
|
||||||
showAlert(
|
showAlert(
|
||||||
'Success',
|
'Success',
|
||||||
'PDF decrypted successfully! Your download has started.',
|
'PDF decrypted successfully! Your download has started.',
|
||||||
'success',
|
'success',
|
||||||
() => { resetState(); }
|
() => {
|
||||||
|
resetState();
|
||||||
|
}
|
||||||
);
|
);
|
||||||
} catch (error: any) {
|
} finally {
|
||||||
console.error('Error during PDF decryption:', error);
|
|
||||||
if (loaderModal) loaderModal.classList.add('hidden');
|
|
||||||
|
|
||||||
if (error.message === 'INVALID_PASSWORD') {
|
|
||||||
showAlert(
|
|
||||||
'Incorrect Password',
|
|
||||||
'The password you entered is incorrect. Please try again.'
|
|
||||||
);
|
|
||||||
} else if (error.message?.includes('password')) {
|
|
||||||
showAlert(
|
|
||||||
'Password Error',
|
|
||||||
'Unable to decrypt the PDF with the provided password.'
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
showAlert(
|
|
||||||
'Decryption Failed',
|
|
||||||
`An error occurred: ${error.message || 'The password you entered is wrong or the file is corrupted.'}`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
try {
|
try {
|
||||||
if (qpdf?.FS) {
|
if (qpdf?.FS) {
|
||||||
try {
|
if (qpdf.FS.analyzePath(inputPath).exists)
|
||||||
qpdf.FS.unlink(inputPath);
|
qpdf.FS.unlink(inputPath);
|
||||||
} catch (e) {
|
if (qpdf.FS.analyzePath(outputPath).exists)
|
||||||
console.warn('Failed to unlink input file:', e);
|
qpdf.FS.unlink(outputPath);
|
||||||
}
|
}
|
||||||
try {
|
|
||||||
qpdf.FS.unlink(outputPath);
|
|
||||||
} catch (e) {
|
|
||||||
console.warn('Failed to unlink output file:', e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (cleanupError) {
|
} catch (cleanupError) {
|
||||||
console.warn('Failed to cleanup WASM FS:', cleanupError);
|
console.warn('Failed to cleanup WASM FS:', cleanupError);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Multiple files: decrypt all and download as ZIP
|
||||||
|
const zip = new JSZip();
|
||||||
|
let successCount = 0;
|
||||||
|
let errorCount = 0;
|
||||||
|
|
||||||
|
for (let i = 0; i < pageState.files.length; i++) {
|
||||||
|
const file = pageState.files[i];
|
||||||
|
const inputPath = `/input_${i}.pdf`;
|
||||||
|
const outputPath = `/output_${i}.pdf`;
|
||||||
|
|
||||||
|
if (loaderText)
|
||||||
|
loaderText.textContent = `Decrypting ${file.name} (${i + 1}/${pageState.files.length})...`;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const fileBuffer = await readFileAsArrayBuffer(file);
|
||||||
|
const uint8Array = new Uint8Array(fileBuffer as ArrayBuffer);
|
||||||
|
qpdf.FS.writeFile(inputPath, uint8Array);
|
||||||
|
|
||||||
|
const args = [
|
||||||
|
inputPath,
|
||||||
|
'--password=' + password,
|
||||||
|
'--decrypt',
|
||||||
|
outputPath,
|
||||||
|
];
|
||||||
|
|
||||||
|
try {
|
||||||
|
qpdf.callMain(args);
|
||||||
|
} catch (qpdfError: any) {
|
||||||
|
if (
|
||||||
|
qpdfError.message?.includes('invalid password') ||
|
||||||
|
qpdfError.message?.includes('password')
|
||||||
|
) {
|
||||||
|
throw new Error(`Invalid password for ${file.name}`);
|
||||||
|
}
|
||||||
|
throw qpdfError;
|
||||||
|
}
|
||||||
|
|
||||||
|
const outputFile = qpdf.FS.readFile(outputPath, {
|
||||||
|
encoding: 'binary',
|
||||||
|
});
|
||||||
|
if (!outputFile || outputFile.length === 0) {
|
||||||
|
throw new Error(
|
||||||
|
`Decryption resulted in an empty file for ${file.name}.`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
zip.file(`unlocked-${file.name}`, outputFile, { binary: true });
|
||||||
|
successCount++;
|
||||||
|
} catch (fileError: any) {
|
||||||
|
errorCount++;
|
||||||
|
console.error(`Failed to decrypt ${file.name}:`, fileError);
|
||||||
|
} finally {
|
||||||
|
try {
|
||||||
|
if (qpdf?.FS) {
|
||||||
|
if (qpdf.FS.analyzePath(inputPath).exists)
|
||||||
|
qpdf.FS.unlink(inputPath);
|
||||||
|
if (qpdf.FS.analyzePath(outputPath).exists)
|
||||||
|
qpdf.FS.unlink(outputPath);
|
||||||
|
}
|
||||||
|
} catch (cleanupError) {
|
||||||
|
console.warn(
|
||||||
|
`Failed to cleanup WASM FS for ${file.name}:`,
|
||||||
|
cleanupError
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (successCount === 0) {
|
||||||
|
throw new Error(
|
||||||
|
'No PDF files could be decrypted. The password may be incorrect.'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (loaderText) loaderText.textContent = 'Generating ZIP file...';
|
||||||
|
const zipBlob = await zip.generateAsync({ type: 'blob' });
|
||||||
|
downloadFile(zipBlob, 'decrypted-pdfs.zip');
|
||||||
|
|
||||||
|
let alertMessage = `${successCount} PDF(s) decrypted successfully.`;
|
||||||
|
if (errorCount > 0) {
|
||||||
|
alertMessage += ` ${errorCount} file(s) failed.`;
|
||||||
|
}
|
||||||
|
showAlert('Processing Complete', alertMessage, 'success', () => {
|
||||||
|
resetState();
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('Error during PDF decryption:', error);
|
||||||
|
|
||||||
|
if (error.message === 'INVALID_PASSWORD') {
|
||||||
|
showAlert(
|
||||||
|
'Incorrect Password',
|
||||||
|
'The password you entered is incorrect. Please try again.'
|
||||||
|
);
|
||||||
|
} else if (error.message?.includes('password')) {
|
||||||
|
showAlert(
|
||||||
|
'Password Error',
|
||||||
|
'Unable to decrypt the PDF with the provided password.'
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
showAlert(
|
||||||
|
'Decryption Failed',
|
||||||
|
`An error occurred: ${error.message || 'The password you entered is wrong or the file is corrupted.'}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
if (loaderModal) loaderModal.classList.add('hidden');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
document.addEventListener('DOMContentLoaded', function () {
|
document.addEventListener('DOMContentLoaded', function () {
|
||||||
const fileInput = document.getElementById('file-input') as HTMLInputElement;
|
const fileInput = document.getElementById('file-input') as HTMLInputElement;
|
||||||
const dropZone = document.getElementById('drop-zone');
|
const dropZone = document.getElementById('drop-zone');
|
||||||
const processBtn = document.getElementById('process-btn');
|
const processBtn = document.getElementById('process-btn');
|
||||||
const backBtn = document.getElementById('back-to-tools');
|
const addMoreBtn = document.getElementById('add-more-btn');
|
||||||
|
const clearFilesBtn = document.getElementById('clear-files-btn');
|
||||||
|
const backBtn = document.getElementById('back-to-tools');
|
||||||
|
|
||||||
if (backBtn) {
|
if (backBtn) {
|
||||||
backBtn.addEventListener('click', function () {
|
backBtn.addEventListener('click', function () {
|
||||||
window.location.href = import.meta.env.BASE_URL;
|
window.location.href = import.meta.env.BASE_URL;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (fileInput && dropZone) {
|
if (fileInput && dropZone) {
|
||||||
fileInput.addEventListener('change', function (e) {
|
fileInput.addEventListener('change', function (e) {
|
||||||
handleFileSelect((e.target as HTMLInputElement).files);
|
handleFileSelect((e.target as HTMLInputElement).files);
|
||||||
});
|
});
|
||||||
|
|
||||||
dropZone.addEventListener('dragover', function (e) {
|
dropZone.addEventListener('dragover', function (e) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
dropZone.classList.add('bg-gray-700');
|
dropZone.classList.add('bg-gray-700');
|
||||||
});
|
});
|
||||||
|
|
||||||
dropZone.addEventListener('dragleave', function (e) {
|
dropZone.addEventListener('dragleave', function (e) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
dropZone.classList.remove('bg-gray-700');
|
dropZone.classList.remove('bg-gray-700');
|
||||||
});
|
});
|
||||||
|
|
||||||
dropZone.addEventListener('drop', function (e) {
|
dropZone.addEventListener('drop', function (e) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
dropZone.classList.remove('bg-gray-700');
|
dropZone.classList.remove('bg-gray-700');
|
||||||
const files = e.dataTransfer?.files;
|
handleFileSelect(e.dataTransfer?.files);
|
||||||
if (files && files.length > 0) {
|
});
|
||||||
const pdfFiles = Array.from(files).filter(function (f) {
|
|
||||||
return f.type === 'application/pdf' || f.name.toLowerCase().endsWith('.pdf');
|
|
||||||
});
|
|
||||||
if (pdfFiles.length > 0) {
|
|
||||||
const dataTransfer = new DataTransfer();
|
|
||||||
dataTransfer.items.add(pdfFiles[0]);
|
|
||||||
handleFileSelect(dataTransfer.files);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
fileInput.addEventListener('click', function () {
|
fileInput.addEventListener('click', function () {
|
||||||
fileInput.value = '';
|
fileInput.value = '';
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (processBtn) {
|
if (processBtn) {
|
||||||
processBtn.addEventListener('click', decryptPdf);
|
processBtn.addEventListener('click', decryptPdf);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (addMoreBtn) {
|
||||||
|
addMoreBtn.addEventListener('click', function () {
|
||||||
|
fileInput.value = '';
|
||||||
|
fileInput.click();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (clearFilesBtn) {
|
||||||
|
clearFilesBtn.addEventListener('click', function () {
|
||||||
|
resetState();
|
||||||
|
});
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -159,7 +159,7 @@ async function renderThumbnails() {
|
|||||||
|
|
||||||
for (let i = 1; i <= deleteState.totalPages; i++) {
|
for (let i = 1; i <= deleteState.totalPages; i++) {
|
||||||
const page = await deleteState.pdfJsDoc.getPage(i);
|
const page = await deleteState.pdfJsDoc.getPage(i);
|
||||||
const viewport = page.getViewport({ scale: 0.3 });
|
const viewport = page.getViewport({ scale: 1 });
|
||||||
|
|
||||||
const canvas = document.createElement('canvas');
|
const canvas = document.createElement('canvas');
|
||||||
canvas.width = viewport.width;
|
canvas.width = viewport.width;
|
||||||
|
|||||||
@@ -10,11 +10,18 @@ import {
|
|||||||
PDFDict,
|
PDFDict,
|
||||||
PDFArray,
|
PDFArray,
|
||||||
PDFRadioGroup,
|
PDFRadioGroup,
|
||||||
|
PDFTextField,
|
||||||
|
PDFCheckBox,
|
||||||
|
PDFDropdown,
|
||||||
|
PDFOptionList,
|
||||||
|
PDFButton,
|
||||||
|
PDFSignature,
|
||||||
} from 'pdf-lib';
|
} from 'pdf-lib';
|
||||||
import { initializeGlobalShortcuts } from '../utils/shortcuts-init.js';
|
import { initializeGlobalShortcuts } from '../utils/shortcuts-init.js';
|
||||||
import { downloadFile, hexToRgb, getPDFDocument } from '../utils/helpers.js';
|
import { downloadFile, hexToRgb, getPDFDocument } from '../utils/helpers.js';
|
||||||
import { createIcons, icons } from 'lucide';
|
import { createIcons, icons } from 'lucide';
|
||||||
import * as pdfjsLib from 'pdfjs-dist';
|
import * as pdfjsLib from 'pdfjs-dist';
|
||||||
|
import * as bwipjs from 'bwip-js/browser';
|
||||||
import 'pdfjs-dist/web/pdf_viewer.css';
|
import 'pdfjs-dist/web/pdf_viewer.css';
|
||||||
|
|
||||||
// Initialize PDF.js worker
|
// Initialize PDF.js worker
|
||||||
@@ -33,6 +40,7 @@ const existingRadioGroups: Set<string> = new Set();
|
|||||||
let draggedElement: HTMLElement | null = null;
|
let draggedElement: HTMLElement | null = null;
|
||||||
let offsetX = 0;
|
let offsetX = 0;
|
||||||
let offsetY = 0;
|
let offsetY = 0;
|
||||||
|
let pendingFieldExtraction = false;
|
||||||
|
|
||||||
let pages: PageData[] = [];
|
let pages: PageData[] = [];
|
||||||
let currentPageIndex = 0;
|
let currentPageIndex = 0;
|
||||||
@@ -384,8 +392,18 @@ function createField(type: FormField['type'], x: number, y: number): void {
|
|||||||
type: type,
|
type: type,
|
||||||
x: Math.max(0, Math.min(x, 816 - 150)),
|
x: Math.max(0, Math.min(x, 816 - 150)),
|
||||||
y: Math.max(0, Math.min(y, 1056 - 30)),
|
y: Math.max(0, Math.min(y, 1056 - 30)),
|
||||||
width: type === 'checkbox' || type === 'radio' ? 30 : 150,
|
width:
|
||||||
height: type === 'checkbox' || type === 'radio' ? 30 : 30,
|
type === 'checkbox' || type === 'radio'
|
||||||
|
? 30
|
||||||
|
: type === 'barcode'
|
||||||
|
? 150
|
||||||
|
: 150,
|
||||||
|
height:
|
||||||
|
type === 'checkbox' || type === 'radio'
|
||||||
|
? 30
|
||||||
|
: type === 'barcode'
|
||||||
|
? 150
|
||||||
|
: 30,
|
||||||
name: `${type.charAt(0).toUpperCase() + type.slice(1)}_${fieldCounter}`,
|
name: `${type.charAt(0).toUpperCase() + type.slice(1)}_${fieldCounter}`,
|
||||||
defaultValue: '',
|
defaultValue: '',
|
||||||
fontSize: 12,
|
fontSize: 12,
|
||||||
@@ -417,6 +435,8 @@ function createField(type: FormField['type'], x: number, y: number): void {
|
|||||||
multiline: type === 'text' ? false : undefined,
|
multiline: type === 'text' ? false : undefined,
|
||||||
borderColor: '#000000',
|
borderColor: '#000000',
|
||||||
hideBorder: false,
|
hideBorder: false,
|
||||||
|
barcodeFormat: type === 'barcode' ? 'qrcode' : undefined,
|
||||||
|
barcodeValue: type === 'barcode' ? 'https://example.com' : undefined,
|
||||||
};
|
};
|
||||||
|
|
||||||
fields.push(field);
|
fields.push(field);
|
||||||
@@ -575,6 +595,32 @@ function renderField(field: FormField): void {
|
|||||||
'w-full h-full flex items-center justify-center bg-gray-100 text-gray-500 border border-gray-300';
|
'w-full h-full flex items-center justify-center bg-gray-100 text-gray-500 border border-gray-300';
|
||||||
contentEl.innerHTML = `<div class="flex flex-col items-center text-center p-1"><i data-lucide="image" class="w-6 h-6 mb-1"></i><span class="text-[10px] leading-tight">${field.label || 'Click to Upload Image'}</span></div>`;
|
contentEl.innerHTML = `<div class="flex flex-col items-center text-center p-1"><i data-lucide="image" class="w-6 h-6 mb-1"></i><span class="text-[10px] leading-tight">${field.label || 'Click to Upload Image'}</span></div>`;
|
||||||
setTimeout(() => (window as any).lucide?.createIcons(), 0);
|
setTimeout(() => (window as any).lucide?.createIcons(), 0);
|
||||||
|
} else if (field.type === 'barcode') {
|
||||||
|
contentEl.className =
|
||||||
|
'w-full h-full flex items-center justify-center bg-white';
|
||||||
|
if (field.barcodeValue) {
|
||||||
|
try {
|
||||||
|
const offscreen = document.createElement('canvas');
|
||||||
|
bwipjs.toCanvas(offscreen, {
|
||||||
|
bcid: field.barcodeFormat || 'qrcode',
|
||||||
|
text: field.barcodeValue,
|
||||||
|
scale: 2,
|
||||||
|
includetext:
|
||||||
|
field.barcodeFormat !== 'qrcode' &&
|
||||||
|
field.barcodeFormat !== 'datamatrix',
|
||||||
|
});
|
||||||
|
const img = document.createElement('img');
|
||||||
|
img.src = offscreen.toDataURL('image/png');
|
||||||
|
img.className = 'max-w-full max-h-full object-contain';
|
||||||
|
contentEl.appendChild(img);
|
||||||
|
} catch {
|
||||||
|
contentEl.innerHTML = `<div class="flex flex-col items-center text-center p-1 text-gray-400"><i data-lucide="qr-code" class="w-6 h-6 mb-1"></i><span class="text-[10px] leading-tight">Invalid data</span></div>`;
|
||||||
|
setTimeout(() => (window as any).lucide?.createIcons(), 0);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
contentEl.innerHTML = `<div class="flex flex-col items-center text-center p-1 text-gray-400"><i data-lucide="qr-code" class="w-6 h-6 mb-1"></i><span class="text-[10px] leading-tight">Barcode</span></div>`;
|
||||||
|
setTimeout(() => (window as any).lucide?.createIcons(), 0);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fieldContainer.appendChild(contentEl);
|
fieldContainer.appendChild(contentEl);
|
||||||
@@ -931,7 +977,7 @@ function showProperties(field: FormField): void {
|
|||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label class="block text-xs font-semibold text-gray-300 mb-1">Text Color</label>
|
<label class="block text-xs font-semibold text-gray-300 mb-1">Text Color</label>
|
||||||
<input type="color" id="propTextColor" value="${field.textColor}" class="w-full border border-gray-500 rounded px-2 py-1 h-10">
|
<input type="color" id="propTextColor" value="${field.textColor}">
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label class="block text-xs font-semibold text-gray-300 mb-1">Alignment</label>
|
<label class="block text-xs font-semibold text-gray-300 mb-1">Alignment</label>
|
||||||
@@ -1114,6 +1160,26 @@ function showProperties(field: FormField): void {
|
|||||||
Clicking this field in the PDF will open a file picker to upload an image.
|
Clicking this field in the PDF will open a file picker to upload an image.
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
|
} else if (field.type === 'barcode') {
|
||||||
|
specificProps = `
|
||||||
|
<div>
|
||||||
|
<label class="block text-xs font-semibold text-gray-300 mb-1">Barcode Format</label>
|
||||||
|
<select id="propBarcodeFormat" class="w-full bg-gray-600 border border-gray-500 text-white rounded px-2 py-1 text-sm focus:ring-indigo-500 focus:border-indigo-500">
|
||||||
|
<option value="qrcode" ${field.barcodeFormat === 'qrcode' ? 'selected' : ''}>QR Code</option>
|
||||||
|
<option value="code128" ${field.barcodeFormat === 'code128' ? 'selected' : ''}>Code 128</option>
|
||||||
|
<option value="code39" ${field.barcodeFormat === 'code39' ? 'selected' : ''}>Code 39</option>
|
||||||
|
<option value="ean13" ${field.barcodeFormat === 'ean13' ? 'selected' : ''}>EAN-13</option>
|
||||||
|
<option value="upca" ${field.barcodeFormat === 'upca' ? 'selected' : ''}>UPC-A</option>
|
||||||
|
<option value="datamatrix" ${field.barcodeFormat === 'datamatrix' ? 'selected' : ''}>DataMatrix</option>
|
||||||
|
<option value="pdf417" ${field.barcodeFormat === 'pdf417' ? 'selected' : ''}>PDF417</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-xs font-semibold text-gray-300 mb-1">Barcode Value</label>
|
||||||
|
<input type="text" id="propBarcodeValue" value="${field.barcodeValue || ''}" class="w-full bg-gray-600 border border-gray-500 text-white rounded px-2 py-1 text-sm focus:ring-indigo-500 focus:border-indigo-500">
|
||||||
|
</div>
|
||||||
|
<div id="barcodeFormatHint" class="text-xs text-gray-400 italic"></div>
|
||||||
|
`;
|
||||||
}
|
}
|
||||||
|
|
||||||
propertiesPanel.innerHTML = `
|
propertiesPanel.innerHTML = `
|
||||||
@@ -1169,7 +1235,7 @@ function showProperties(field: FormField): void {
|
|||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label class="block text-xs font-semibold text-gray-300 mb-1">Border Color</label>
|
<label class="block text-xs font-semibold text-gray-300 mb-1">Border Color</label>
|
||||||
<input type="color" id="propBorderColor" value="${field.borderColor || '#000000'}" class="w-full border border-gray-500 rounded px-2 py-1 h-10">
|
<input type="color" id="propBorderColor" value="${field.borderColor || '#000000'}">
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center">
|
<div class="flex items-center">
|
||||||
<input type="checkbox" id="propHideBorder" ${field.hideBorder ? 'checked' : ''} class="mr-2">
|
<input type="checkbox" id="propHideBorder" ${field.hideBorder ? 'checked' : ''} class="mr-2">
|
||||||
@@ -1795,6 +1861,56 @@ function showProperties(field: FormField): void {
|
|||||||
field.label = (e.target as HTMLInputElement).value;
|
field.label = (e.target as HTMLInputElement).value;
|
||||||
renderField(field);
|
renderField(field);
|
||||||
});
|
});
|
||||||
|
} else if (field.type === 'barcode') {
|
||||||
|
const propBarcodeFormat = document.getElementById(
|
||||||
|
'propBarcodeFormat'
|
||||||
|
) as HTMLSelectElement;
|
||||||
|
const propBarcodeValue = document.getElementById(
|
||||||
|
'propBarcodeValue'
|
||||||
|
) as HTMLInputElement;
|
||||||
|
|
||||||
|
const barcodeSampleValues: Record<string, string> = {
|
||||||
|
qrcode: 'https://example.com',
|
||||||
|
code128: 'ABC-123',
|
||||||
|
code39: 'ABC123',
|
||||||
|
ean13: '590123412345',
|
||||||
|
upca: '01234567890',
|
||||||
|
datamatrix: 'https://example.com',
|
||||||
|
pdf417: 'https://example.com',
|
||||||
|
};
|
||||||
|
|
||||||
|
const barcodeFormatHints: Record<string, string> = {
|
||||||
|
qrcode: 'Any text, URL, or data',
|
||||||
|
code128: 'ASCII characters (letters, numbers, symbols)',
|
||||||
|
code39: 'Uppercase A-Z, digits 0-9, and - . $ / + % SPACE',
|
||||||
|
ean13: 'Exactly 12 or 13 digits',
|
||||||
|
upca: 'Exactly 11 or 12 digits',
|
||||||
|
datamatrix: 'Any text, URL, or data',
|
||||||
|
pdf417: 'Any text, URL, or data',
|
||||||
|
};
|
||||||
|
|
||||||
|
const hintEl = document.getElementById('barcodeFormatHint');
|
||||||
|
if (hintEl)
|
||||||
|
hintEl.textContent =
|
||||||
|
barcodeFormatHints[field.barcodeFormat || 'qrcode'] || '';
|
||||||
|
|
||||||
|
if (propBarcodeFormat) {
|
||||||
|
propBarcodeFormat.addEventListener('change', (e) => {
|
||||||
|
const newFormat = (e.target as HTMLSelectElement).value;
|
||||||
|
field.barcodeFormat = newFormat;
|
||||||
|
field.barcodeValue = barcodeSampleValues[newFormat] || 'hello';
|
||||||
|
if (propBarcodeValue) propBarcodeValue.value = field.barcodeValue;
|
||||||
|
if (hintEl) hintEl.textContent = barcodeFormatHints[newFormat] || '';
|
||||||
|
renderField(field);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (propBarcodeValue) {
|
||||||
|
propBarcodeValue.addEventListener('input', (e) => {
|
||||||
|
field.barcodeValue = (e.target as HTMLInputElement).value;
|
||||||
|
renderField(field);
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1911,6 +2027,19 @@ downloadBtn.addEventListener('click', async () => {
|
|||||||
|
|
||||||
const form = pdfDoc.getForm();
|
const form = pdfDoc.getForm();
|
||||||
|
|
||||||
|
if (extractedFieldNames.size > 0) {
|
||||||
|
for (const fieldName of extractedFieldNames) {
|
||||||
|
try {
|
||||||
|
const existingField = form.getFieldMaybe(fieldName);
|
||||||
|
if (existingField) {
|
||||||
|
form.removeField(existingField);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.warn(`Failed to remove existing field "${fieldName}":`, e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const helveticaFont = await pdfDoc.embedFont(StandardFonts.Helvetica);
|
const helveticaFont = await pdfDoc.embedFont(StandardFonts.Helvetica);
|
||||||
|
|
||||||
// Set document metadata for accessibility
|
// Set document metadata for accessibility
|
||||||
@@ -2332,6 +2461,32 @@ downloadBtn.addEventListener('click', async () => {
|
|||||||
if (field.tooltip) {
|
if (field.tooltip) {
|
||||||
widgetDict.set(PDFName.of('TU'), PDFString.of(field.tooltip));
|
widgetDict.set(PDFName.of('TU'), PDFString.of(field.tooltip));
|
||||||
}
|
}
|
||||||
|
} else if (field.type === 'barcode') {
|
||||||
|
if (field.barcodeValue) {
|
||||||
|
try {
|
||||||
|
const offscreen = document.createElement('canvas');
|
||||||
|
bwipjs.toCanvas(offscreen, {
|
||||||
|
bcid: field.barcodeFormat || 'qrcode',
|
||||||
|
text: field.barcodeValue,
|
||||||
|
scale: 3,
|
||||||
|
includetext:
|
||||||
|
field.barcodeFormat !== 'qrcode' &&
|
||||||
|
field.barcodeFormat !== 'datamatrix',
|
||||||
|
});
|
||||||
|
const dataUrl = offscreen.toDataURL('image/png');
|
||||||
|
const base64 = dataUrl.split(',')[1];
|
||||||
|
const pngBytes = Uint8Array.from(atob(base64), (c) =>
|
||||||
|
c.charCodeAt(0)
|
||||||
|
);
|
||||||
|
const pngImage = await pdfDoc.embedPng(pngBytes);
|
||||||
|
pdfPage.drawImage(pngImage, { x, y, width, height });
|
||||||
|
} catch (e) {
|
||||||
|
console.warn(
|
||||||
|
`Failed to generate barcode for field "${field.name}":`,
|
||||||
|
e
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2429,6 +2584,8 @@ function resetToInitial(): void {
|
|||||||
currentPageIndex = 0;
|
currentPageIndex = 0;
|
||||||
uploadedPdfDoc = null;
|
uploadedPdfDoc = null;
|
||||||
selectedField = null;
|
selectedField = null;
|
||||||
|
extractedFieldNames.clear();
|
||||||
|
pendingFieldExtraction = false;
|
||||||
|
|
||||||
canvas.innerHTML = '';
|
canvas.innerHTML = '';
|
||||||
|
|
||||||
@@ -2611,6 +2768,27 @@ async function renderCanvas(): Promise<void> {
|
|||||||
height: currentPage.height,
|
height: currentPage.height,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (pendingFieldExtraction && uploadedPdfDoc) {
|
||||||
|
pendingFieldExtraction = false;
|
||||||
|
extractExistingFields(uploadedPdfDoc);
|
||||||
|
extractedFieldNames.forEach((name) =>
|
||||||
|
existingFieldNames.delete(name)
|
||||||
|
);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const form = uploadedPdfDoc.getForm();
|
||||||
|
for (const name of extractedFieldNames) {
|
||||||
|
try {
|
||||||
|
const f = form.getFieldMaybe(name);
|
||||||
|
if (f) form.removeField(f);
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
|
||||||
|
renderCanvas();
|
||||||
|
updateFieldCount();
|
||||||
|
}
|
||||||
}, 50);
|
}, 50);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -2701,6 +2879,235 @@ confirmBlankBtn.addEventListener('click', () => {
|
|||||||
setTimeout(() => createIcons({ icons }), 100);
|
setTimeout(() => createIcons({ icons }), 100);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const extractedFieldNames: Set<string> = new Set();
|
||||||
|
|
||||||
|
function extractExistingFields(pdfDoc: PDFDocument): void {
|
||||||
|
try {
|
||||||
|
const form = pdfDoc.getForm();
|
||||||
|
const pdfFields = form.getFields();
|
||||||
|
const pdfPages = pdfDoc.getPages();
|
||||||
|
|
||||||
|
const pageRefToIndex = new Map<any, number>();
|
||||||
|
pdfPages.forEach((page, index) => {
|
||||||
|
pageRefToIndex.set(page.ref, index);
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const pdfField of pdfFields) {
|
||||||
|
const name = pdfField.getName();
|
||||||
|
const widgets = pdfField.acroField.getWidgets();
|
||||||
|
|
||||||
|
if (widgets.length === 0) continue;
|
||||||
|
|
||||||
|
let fieldType: FormField['type'];
|
||||||
|
if (pdfField instanceof PDFTextField) {
|
||||||
|
fieldType = 'text';
|
||||||
|
} else if (pdfField instanceof PDFCheckBox) {
|
||||||
|
fieldType = 'checkbox';
|
||||||
|
} else if (pdfField instanceof PDFRadioGroup) {
|
||||||
|
fieldType = 'radio';
|
||||||
|
} else if (pdfField instanceof PDFDropdown) {
|
||||||
|
fieldType = 'dropdown';
|
||||||
|
} else if (pdfField instanceof PDFOptionList) {
|
||||||
|
fieldType = 'optionlist';
|
||||||
|
} else if (pdfField instanceof PDFButton) {
|
||||||
|
fieldType = 'button';
|
||||||
|
} else if (pdfField instanceof PDFSignature) {
|
||||||
|
fieldType = 'signature';
|
||||||
|
} else {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (fieldType === 'radio') {
|
||||||
|
const radioField = pdfField as PDFRadioGroup;
|
||||||
|
const options = radioField.getOptions();
|
||||||
|
|
||||||
|
for (let wi = 0; wi < widgets.length; wi++) {
|
||||||
|
const widget = widgets[wi];
|
||||||
|
const rect = widget.getRectangle();
|
||||||
|
|
||||||
|
const pageRef = widget.dict.get(PDFName.of('P'));
|
||||||
|
let pageIndex = 0;
|
||||||
|
if (pageRef) {
|
||||||
|
for (let pi = 0; pi < pdfPages.length; pi++) {
|
||||||
|
if (pdfPages[pi].ref === pageRef) {
|
||||||
|
pageIndex = pi;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const page = pdfPages[pageIndex];
|
||||||
|
const { height: pageHeight } = page.getSize();
|
||||||
|
|
||||||
|
const canvasX = rect.x * pdfViewerScale + pdfViewerOffset.x;
|
||||||
|
const canvasY =
|
||||||
|
(pageHeight - rect.y - rect.height) * pdfViewerScale +
|
||||||
|
pdfViewerOffset.y;
|
||||||
|
const canvasWidth = rect.width * pdfViewerScale;
|
||||||
|
const canvasHeight = rect.height * pdfViewerScale;
|
||||||
|
|
||||||
|
fieldCounter++;
|
||||||
|
const exportValue = options[wi] || 'Yes';
|
||||||
|
|
||||||
|
let tooltip = '';
|
||||||
|
try {
|
||||||
|
const tu = widget.dict.get(PDFName.of('TU'));
|
||||||
|
if (tu instanceof PDFString) {
|
||||||
|
tooltip = tu.decodeText();
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
|
||||||
|
const formField: FormField = {
|
||||||
|
id: `field_${fieldCounter}`,
|
||||||
|
type: 'radio',
|
||||||
|
x: canvasX,
|
||||||
|
y: canvasY,
|
||||||
|
width: canvasWidth,
|
||||||
|
height: canvasHeight,
|
||||||
|
name: name,
|
||||||
|
defaultValue: '',
|
||||||
|
fontSize: 12,
|
||||||
|
alignment: 'left',
|
||||||
|
textColor: '#000000',
|
||||||
|
required: radioField.isRequired(),
|
||||||
|
readOnly: radioField.isReadOnly(),
|
||||||
|
tooltip: tooltip,
|
||||||
|
combCells: 0,
|
||||||
|
maxLength: 0,
|
||||||
|
checked: false,
|
||||||
|
exportValue: exportValue,
|
||||||
|
groupName: name,
|
||||||
|
pageIndex: pageIndex,
|
||||||
|
borderColor: '#000000',
|
||||||
|
hideBorder: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
fields.push(formField);
|
||||||
|
}
|
||||||
|
|
||||||
|
extractedFieldNames.add(name);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const widget = widgets[0];
|
||||||
|
const rect = widget.getRectangle();
|
||||||
|
|
||||||
|
const pageRef = widget.dict.get(PDFName.of('P'));
|
||||||
|
let pageIndex = 0;
|
||||||
|
if (pageRef) {
|
||||||
|
for (let pi = 0; pi < pdfPages.length; pi++) {
|
||||||
|
if (pdfPages[pi].ref === pageRef) {
|
||||||
|
pageIndex = pi;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const page = pdfPages[pageIndex];
|
||||||
|
const { height: pageHeight } = page.getSize();
|
||||||
|
|
||||||
|
const canvasX = rect.x * pdfViewerScale + pdfViewerOffset.x;
|
||||||
|
const canvasY =
|
||||||
|
(pageHeight - rect.y - rect.height) * pdfViewerScale +
|
||||||
|
pdfViewerOffset.y;
|
||||||
|
const canvasWidth = rect.width * pdfViewerScale;
|
||||||
|
const canvasHeight = rect.height * pdfViewerScale;
|
||||||
|
|
||||||
|
let tooltip = '';
|
||||||
|
try {
|
||||||
|
const tu = widget.dict.get(PDFName.of('TU'));
|
||||||
|
if (tu instanceof PDFString) {
|
||||||
|
tooltip = tu.decodeText();
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
|
||||||
|
fieldCounter++;
|
||||||
|
|
||||||
|
const formField: FormField = {
|
||||||
|
id: `field_${fieldCounter}`,
|
||||||
|
type: fieldType,
|
||||||
|
x: canvasX,
|
||||||
|
y: canvasY,
|
||||||
|
width: canvasWidth,
|
||||||
|
height: canvasHeight,
|
||||||
|
name: name,
|
||||||
|
defaultValue: '',
|
||||||
|
fontSize: 12,
|
||||||
|
alignment: 'left',
|
||||||
|
textColor: '#000000',
|
||||||
|
required: pdfField.isRequired(),
|
||||||
|
readOnly: pdfField.isReadOnly(),
|
||||||
|
tooltip: tooltip,
|
||||||
|
combCells: 0,
|
||||||
|
maxLength: 0,
|
||||||
|
pageIndex: pageIndex,
|
||||||
|
borderColor: '#000000',
|
||||||
|
hideBorder: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (pdfField instanceof PDFTextField) {
|
||||||
|
try {
|
||||||
|
formField.defaultValue = pdfField.getText() || '';
|
||||||
|
} catch {}
|
||||||
|
try {
|
||||||
|
formField.multiline = pdfField.isMultiline();
|
||||||
|
} catch {}
|
||||||
|
try {
|
||||||
|
const maxLen = pdfField.getMaxLength();
|
||||||
|
if (maxLen !== undefined) {
|
||||||
|
if (pdfField.isCombed()) {
|
||||||
|
formField.combCells = maxLen;
|
||||||
|
} else {
|
||||||
|
formField.maxLength = maxLen;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
try {
|
||||||
|
const alignment = pdfField.getAlignment();
|
||||||
|
if (alignment === TextAlignment.Center)
|
||||||
|
formField.alignment = 'center';
|
||||||
|
else if (alignment === TextAlignment.Right)
|
||||||
|
formField.alignment = 'right';
|
||||||
|
else formField.alignment = 'left';
|
||||||
|
} catch {}
|
||||||
|
} else if (pdfField instanceof PDFCheckBox) {
|
||||||
|
try {
|
||||||
|
formField.checked = pdfField.isChecked();
|
||||||
|
} catch {}
|
||||||
|
formField.exportValue = 'Yes';
|
||||||
|
} else if (pdfField instanceof PDFDropdown) {
|
||||||
|
try {
|
||||||
|
formField.options = pdfField.getOptions();
|
||||||
|
} catch {}
|
||||||
|
try {
|
||||||
|
const selected = pdfField.getSelected();
|
||||||
|
if (selected.length > 0) formField.defaultValue = selected[0];
|
||||||
|
} catch {}
|
||||||
|
} else if (pdfField instanceof PDFOptionList) {
|
||||||
|
try {
|
||||||
|
formField.options = pdfField.getOptions();
|
||||||
|
} catch {}
|
||||||
|
try {
|
||||||
|
const selected = pdfField.getSelected();
|
||||||
|
if (selected.length > 0) formField.defaultValue = selected[0];
|
||||||
|
} catch {}
|
||||||
|
} else if (pdfField instanceof PDFButton) {
|
||||||
|
formField.label = 'Button';
|
||||||
|
formField.action = 'none';
|
||||||
|
}
|
||||||
|
|
||||||
|
fields.push(formField);
|
||||||
|
extractedFieldNames.add(name);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(
|
||||||
|
`Extracted ${extractedFieldNames.size} existing fields for editing`
|
||||||
|
);
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('Error extracting existing fields:', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function handlePdfUpload(file: File) {
|
async function handlePdfUpload(file: File) {
|
||||||
try {
|
try {
|
||||||
const arrayBuffer = await file.arrayBuffer();
|
const arrayBuffer = await file.arrayBuffer();
|
||||||
@@ -2759,6 +3166,9 @@ async function handlePdfUpload(file: File) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
currentPageIndex = 0;
|
currentPageIndex = 0;
|
||||||
|
|
||||||
|
pendingFieldExtraction = true;
|
||||||
|
|
||||||
renderCanvas();
|
renderCanvas();
|
||||||
updatePageNavigation();
|
updatePageNavigation();
|
||||||
|
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
renderPagesProgressively,
|
renderPagesProgressively,
|
||||||
cleanupLazyRendering,
|
cleanupLazyRendering,
|
||||||
} from '../utils/render-utils.js';
|
} from '../utils/render-utils.js';
|
||||||
|
import { initPagePreview } from '../utils/page-preview.js';
|
||||||
import { isCpdfAvailable } from '../utils/cpdf-helper.js';
|
import { isCpdfAvailable } from '../utils/cpdf-helper.js';
|
||||||
import {
|
import {
|
||||||
showWasmRequiredDialog,
|
showWasmRequiredDialog,
|
||||||
@@ -210,6 +211,8 @@ async function renderPageMergeThumbnails() {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
|
initPagePreview(container, pdfjsDoc);
|
||||||
}
|
}
|
||||||
|
|
||||||
mergeState.cachedThumbnails = true;
|
mergeState.cachedThumbnails = true;
|
||||||
|
|||||||
+330
-270
@@ -1,365 +1,425 @@
|
|||||||
import { createIcons, icons } from 'lucide';
|
import { createIcons, icons } from 'lucide';
|
||||||
import { showAlert, showLoader, hideLoader } from '../ui.js';
|
import { showAlert, showLoader, hideLoader } from '../ui.js';
|
||||||
import { readFileAsArrayBuffer, formatBytes, downloadFile, getPDFDocument } from '../utils/helpers.js';
|
import {
|
||||||
|
readFileAsArrayBuffer,
|
||||||
|
formatBytes,
|
||||||
|
downloadFile,
|
||||||
|
getPDFDocument,
|
||||||
|
} from '../utils/helpers.js';
|
||||||
|
import { initPagePreview } from '../utils/page-preview.js';
|
||||||
import { PDFDocument } from 'pdf-lib';
|
import { PDFDocument } from 'pdf-lib';
|
||||||
import * as pdfjsLib from 'pdfjs-dist';
|
import * as pdfjsLib from 'pdfjs-dist';
|
||||||
import Sortable from 'sortablejs';
|
import Sortable from 'sortablejs';
|
||||||
|
|
||||||
pdfjsLib.GlobalWorkerOptions.workerSrc = new URL('pdfjs-dist/build/pdf.worker.min.mjs', import.meta.url).toString();
|
pdfjsLib.GlobalWorkerOptions.workerSrc = new URL(
|
||||||
|
'pdfjs-dist/build/pdf.worker.min.mjs',
|
||||||
|
import.meta.url
|
||||||
|
).toString();
|
||||||
|
|
||||||
interface OrganizeState {
|
interface OrganizeState {
|
||||||
file: File | null;
|
file: File | null;
|
||||||
pdfDoc: any;
|
pdfDoc: any;
|
||||||
pdfJsDoc: any;
|
pdfJsDoc: any;
|
||||||
totalPages: number;
|
totalPages: number;
|
||||||
sortableInstance: any;
|
sortableInstance: any;
|
||||||
}
|
}
|
||||||
|
|
||||||
const organizeState: OrganizeState = {
|
const organizeState: OrganizeState = {
|
||||||
file: null,
|
file: null,
|
||||||
pdfDoc: null,
|
pdfDoc: null,
|
||||||
pdfJsDoc: null,
|
pdfJsDoc: null,
|
||||||
totalPages: 0,
|
totalPages: 0,
|
||||||
sortableInstance: null,
|
sortableInstance: null,
|
||||||
};
|
};
|
||||||
|
|
||||||
if (document.readyState === 'loading') {
|
if (document.readyState === 'loading') {
|
||||||
document.addEventListener('DOMContentLoaded', initializePage);
|
document.addEventListener('DOMContentLoaded', initializePage);
|
||||||
} else {
|
} else {
|
||||||
initializePage();
|
initializePage();
|
||||||
}
|
}
|
||||||
|
|
||||||
function initializePage() {
|
function initializePage() {
|
||||||
createIcons({ icons });
|
createIcons({ icons });
|
||||||
|
|
||||||
const fileInput = document.getElementById('file-input') as HTMLInputElement;
|
const fileInput = document.getElementById('file-input') as HTMLInputElement;
|
||||||
const dropZone = document.getElementById('drop-zone');
|
const dropZone = document.getElementById('drop-zone');
|
||||||
const processBtn = document.getElementById('process-btn');
|
const processBtn = document.getElementById('process-btn');
|
||||||
|
|
||||||
if (fileInput) fileInput.addEventListener('change', handleFileUpload);
|
if (fileInput) fileInput.addEventListener('change', handleFileUpload);
|
||||||
|
|
||||||
if (dropZone) {
|
if (dropZone) {
|
||||||
dropZone.addEventListener('dragover', (e) => { e.preventDefault(); dropZone.classList.add('bg-gray-700'); });
|
dropZone.addEventListener('dragover', (e) => {
|
||||||
dropZone.addEventListener('dragleave', () => { dropZone.classList.remove('bg-gray-700'); });
|
e.preventDefault();
|
||||||
dropZone.addEventListener('drop', (e) => {
|
dropZone.classList.add('bg-gray-700');
|
||||||
e.preventDefault();
|
|
||||||
dropZone.classList.remove('bg-gray-700');
|
|
||||||
const droppedFiles = e.dataTransfer?.files;
|
|
||||||
if (droppedFiles && droppedFiles.length > 0) handleFile(droppedFiles[0]);
|
|
||||||
});
|
|
||||||
// Clear value on click to allow re-selecting the same file
|
|
||||||
fileInput?.addEventListener('click', () => {
|
|
||||||
if (fileInput) fileInput.value = '';
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (processBtn) processBtn.addEventListener('click', saveChanges);
|
|
||||||
|
|
||||||
document.getElementById('back-to-tools')?.addEventListener('click', () => {
|
|
||||||
window.location.href = import.meta.env.BASE_URL;
|
|
||||||
});
|
});
|
||||||
|
dropZone.addEventListener('dragleave', () => {
|
||||||
|
dropZone.classList.remove('bg-gray-700');
|
||||||
|
});
|
||||||
|
dropZone.addEventListener('drop', (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
dropZone.classList.remove('bg-gray-700');
|
||||||
|
const droppedFiles = e.dataTransfer?.files;
|
||||||
|
if (droppedFiles && droppedFiles.length > 0) handleFile(droppedFiles[0]);
|
||||||
|
});
|
||||||
|
// Clear value on click to allow re-selecting the same file
|
||||||
|
fileInput?.addEventListener('click', () => {
|
||||||
|
if (fileInput) fileInput.value = '';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
const applyOrderBtn = document.getElementById('apply-order-btn');
|
if (processBtn) processBtn.addEventListener('click', saveChanges);
|
||||||
if (applyOrderBtn) applyOrderBtn.addEventListener('click', applyCustomOrder);
|
|
||||||
|
document.getElementById('back-to-tools')?.addEventListener('click', () => {
|
||||||
|
window.location.href = import.meta.env.BASE_URL;
|
||||||
|
});
|
||||||
|
|
||||||
|
const applyOrderBtn = document.getElementById('apply-order-btn');
|
||||||
|
if (applyOrderBtn) applyOrderBtn.addEventListener('click', applyCustomOrder);
|
||||||
}
|
}
|
||||||
|
|
||||||
function applyCustomOrder() {
|
function applyCustomOrder() {
|
||||||
const orderInput = document.getElementById('page-order-input') as HTMLInputElement;
|
const orderInput = document.getElementById(
|
||||||
const grid = document.getElementById('page-grid');
|
'page-order-input'
|
||||||
|
) as HTMLInputElement;
|
||||||
|
const grid = document.getElementById('page-grid');
|
||||||
|
|
||||||
if (!orderInput || !grid) return;
|
if (!orderInput || !grid) return;
|
||||||
|
|
||||||
const orderString = orderInput.value;
|
const orderString = orderInput.value;
|
||||||
if (!orderString) {
|
if (!orderString) {
|
||||||
showAlert('Invalid Order', 'Please enter a page order.');
|
showAlert('Invalid Order', 'Please enter a page order.');
|
||||||
return;
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const newOrder = orderString.split(',').map((s) => parseInt(s.trim(), 10));
|
||||||
|
|
||||||
|
// Validation
|
||||||
|
const currentGridCount = grid.children.length;
|
||||||
|
const validNumbers = newOrder.every((n) => !isNaN(n) && n > 0); // Basic check, will validate against available thumbnails
|
||||||
|
if (!validNumbers) {
|
||||||
|
showAlert('Invalid Page Numbers', `Please enter positive numbers.`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (newOrder.length !== currentGridCount) {
|
||||||
|
showAlert(
|
||||||
|
'Incorrect Page Count',
|
||||||
|
`The number of pages specified (${newOrder.length}) does not match the current number of pages in the document (${currentGridCount}). Please provide a complete ordering for all pages.`
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const uniqueNumbers = new Set(newOrder);
|
||||||
|
if (uniqueNumbers.size !== newOrder.length) {
|
||||||
|
showAlert(
|
||||||
|
'Duplicate Page Numbers',
|
||||||
|
'Please ensure all page numbers in the order are unique.'
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentThumbnails = Array.from(grid.children) as HTMLElement[];
|
||||||
|
const reorderedThumbnails: HTMLElement[] = [];
|
||||||
|
const foundIndices = new Set();
|
||||||
|
|
||||||
|
for (const pageNum of newOrder) {
|
||||||
|
const originalIndexToFind = pageNum - 1; // pageNum is 1-based, originalPageIndex is 0-based
|
||||||
|
const foundThumbnail = currentThumbnails.find(
|
||||||
|
(thumb) =>
|
||||||
|
thumb.dataset.originalPageIndex === originalIndexToFind.toString()
|
||||||
|
);
|
||||||
|
|
||||||
|
if (foundThumbnail) {
|
||||||
|
reorderedThumbnails.push(foundThumbnail);
|
||||||
|
foundIndices.add(originalIndexToFind.toString());
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const newOrder = orderString.split(',').map(s => parseInt(s.trim(), 10));
|
const allOriginalIndicesPresent = currentThumbnails.every((thumb) =>
|
||||||
|
foundIndices.has(thumb.dataset.originalPageIndex)
|
||||||
|
);
|
||||||
|
|
||||||
// Validation
|
if (
|
||||||
const currentGridCount = grid.children.length;
|
reorderedThumbnails.length !== currentGridCount ||
|
||||||
const validNumbers = newOrder.every(n => !isNaN(n) && n > 0); // Basic check, will validate against available thumbnails
|
!allOriginalIndicesPresent
|
||||||
if (!validNumbers) {
|
) {
|
||||||
showAlert('Invalid Page Numbers', `Please enter positive numbers.`);
|
showAlert(
|
||||||
return;
|
'Invalid Page Order',
|
||||||
}
|
'The specified page order is incomplete or contains invalid page numbers. Please ensure you provide a new position for every original page.'
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (newOrder.length !== currentGridCount) {
|
// Clear the grid and append the reordered thumbnails
|
||||||
showAlert('Incorrect Page Count', `The number of pages specified (${newOrder.length}) does not match the current number of pages in the document (${currentGridCount}). Please provide a complete ordering for all pages.`);
|
grid.innerHTML = '';
|
||||||
return;
|
reorderedThumbnails.forEach((thumb) => grid.appendChild(thumb));
|
||||||
}
|
|
||||||
|
|
||||||
const uniqueNumbers = new Set(newOrder);
|
initializeSortable(); // Re-initialize sortable on the new order
|
||||||
if (uniqueNumbers.size !== newOrder.length) {
|
|
||||||
showAlert('Duplicate Page Numbers', 'Please ensure all page numbers in the order are unique.');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const currentThumbnails = Array.from(grid.children) as HTMLElement[];
|
showAlert('Success', 'Pages have been reordered.', 'success');
|
||||||
const reorderedThumbnails: HTMLElement[] = [];
|
|
||||||
const foundIndices = new Set();
|
|
||||||
|
|
||||||
for (const pageNum of newOrder) {
|
|
||||||
const originalIndexToFind = pageNum - 1; // pageNum is 1-based, originalPageIndex is 0-based
|
|
||||||
const foundThumbnail = currentThumbnails.find(
|
|
||||||
thumb => thumb.dataset.originalPageIndex === originalIndexToFind.toString()
|
|
||||||
);
|
|
||||||
|
|
||||||
if (foundThumbnail) {
|
|
||||||
reorderedThumbnails.push(foundThumbnail);
|
|
||||||
foundIndices.add(originalIndexToFind.toString());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const allOriginalIndicesPresent = currentThumbnails.every(thumb => foundIndices.has(thumb.dataset.originalPageIndex));
|
|
||||||
|
|
||||||
if (reorderedThumbnails.length !== currentGridCount || !allOriginalIndicesPresent) {
|
|
||||||
showAlert('Invalid Page Order', 'The specified page order is incomplete or contains invalid page numbers. Please ensure you provide a new position for every original page.');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Clear the grid and append the reordered thumbnails
|
|
||||||
grid.innerHTML = '';
|
|
||||||
reorderedThumbnails.forEach(thumb => grid.appendChild(thumb));
|
|
||||||
|
|
||||||
initializeSortable(); // Re-initialize sortable on the new order
|
|
||||||
|
|
||||||
showAlert('Success', 'Pages have been reordered.', 'success');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleFileUpload(e: Event) {
|
function handleFileUpload(e: Event) {
|
||||||
const input = e.target as HTMLInputElement;
|
const input = e.target as HTMLInputElement;
|
||||||
if (input.files && input.files.length > 0) handleFile(input.files[0]);
|
if (input.files && input.files.length > 0) handleFile(input.files[0]);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleFile(file: File) {
|
async function handleFile(file: File) {
|
||||||
if (file.type !== 'application/pdf' && !file.name.toLowerCase().endsWith('.pdf')) {
|
if (
|
||||||
showAlert('Invalid File', 'Please select a PDF file.');
|
file.type !== 'application/pdf' &&
|
||||||
return;
|
!file.name.toLowerCase().endsWith('.pdf')
|
||||||
}
|
) {
|
||||||
|
showAlert('Invalid File', 'Please select a PDF file.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
showLoader('Loading PDF...');
|
showLoader('Loading PDF...');
|
||||||
organizeState.file = file;
|
organizeState.file = file;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const arrayBuffer = await readFileAsArrayBuffer(file);
|
const arrayBuffer = await readFileAsArrayBuffer(file);
|
||||||
organizeState.pdfDoc = await PDFDocument.load(arrayBuffer as ArrayBuffer, { ignoreEncryption: true, throwOnInvalidObject: false });
|
organizeState.pdfDoc = await PDFDocument.load(arrayBuffer as ArrayBuffer, {
|
||||||
organizeState.pdfJsDoc = await getPDFDocument({ data: (arrayBuffer as ArrayBuffer).slice(0) }).promise;
|
ignoreEncryption: true,
|
||||||
organizeState.totalPages = organizeState.pdfDoc.getPageCount();
|
throwOnInvalidObject: false,
|
||||||
|
});
|
||||||
|
organizeState.pdfJsDoc = await getPDFDocument({
|
||||||
|
data: (arrayBuffer as ArrayBuffer).slice(0),
|
||||||
|
}).promise;
|
||||||
|
organizeState.totalPages = organizeState.pdfDoc.getPageCount();
|
||||||
|
|
||||||
updateFileDisplay();
|
updateFileDisplay();
|
||||||
await renderThumbnails();
|
await renderThumbnails();
|
||||||
hideLoader();
|
hideLoader();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error loading PDF:', error);
|
console.error('Error loading PDF:', error);
|
||||||
hideLoader();
|
hideLoader();
|
||||||
showAlert('Error', 'Failed to load PDF file.');
|
showAlert('Error', 'Failed to load PDF file.');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function updateFileDisplay() {
|
function updateFileDisplay() {
|
||||||
const fileDisplayArea = document.getElementById('file-display-area');
|
const fileDisplayArea = document.getElementById('file-display-area');
|
||||||
if (!fileDisplayArea || !organizeState.file) return;
|
if (!fileDisplayArea || !organizeState.file) return;
|
||||||
|
|
||||||
fileDisplayArea.innerHTML = '';
|
fileDisplayArea.innerHTML = '';
|
||||||
const fileDiv = document.createElement('div');
|
const fileDiv = document.createElement('div');
|
||||||
fileDiv.className = 'flex items-center justify-between bg-gray-700 p-3 rounded-lg';
|
fileDiv.className =
|
||||||
|
'flex items-center justify-between bg-gray-700 p-3 rounded-lg';
|
||||||
|
|
||||||
const infoContainer = document.createElement('div');
|
const infoContainer = document.createElement('div');
|
||||||
infoContainer.className = 'flex flex-col flex-1 min-w-0';
|
infoContainer.className = 'flex flex-col flex-1 min-w-0';
|
||||||
|
|
||||||
const nameSpan = document.createElement('div');
|
const nameSpan = document.createElement('div');
|
||||||
nameSpan.className = 'truncate font-medium text-gray-200 text-sm mb-1';
|
nameSpan.className = 'truncate font-medium text-gray-200 text-sm mb-1';
|
||||||
nameSpan.textContent = organizeState.file.name;
|
nameSpan.textContent = organizeState.file.name;
|
||||||
|
|
||||||
const metaSpan = document.createElement('div');
|
const metaSpan = document.createElement('div');
|
||||||
metaSpan.className = 'text-xs text-gray-400';
|
metaSpan.className = 'text-xs text-gray-400';
|
||||||
metaSpan.textContent = `${formatBytes(organizeState.file.size)} • ${organizeState.totalPages} pages`;
|
metaSpan.textContent = `${formatBytes(organizeState.file.size)} • ${organizeState.totalPages} pages`;
|
||||||
|
|
||||||
infoContainer.append(nameSpan, metaSpan);
|
infoContainer.append(nameSpan, metaSpan);
|
||||||
|
|
||||||
const removeBtn = document.createElement('button');
|
const removeBtn = document.createElement('button');
|
||||||
removeBtn.className = 'ml-4 text-red-400 hover:text-red-300 flex-shrink-0';
|
removeBtn.className = 'ml-4 text-red-400 hover:text-red-300 flex-shrink-0';
|
||||||
removeBtn.innerHTML = '<i data-lucide="trash-2" class="w-4 h-4"></i>';
|
removeBtn.innerHTML = '<i data-lucide="trash-2" class="w-4 h-4"></i>';
|
||||||
removeBtn.onclick = () => resetState();
|
removeBtn.onclick = () => resetState();
|
||||||
|
|
||||||
fileDiv.append(infoContainer, removeBtn);
|
fileDiv.append(infoContainer, removeBtn);
|
||||||
fileDisplayArea.appendChild(fileDiv);
|
fileDisplayArea.appendChild(fileDiv);
|
||||||
createIcons({ icons });
|
createIcons({ icons });
|
||||||
}
|
}
|
||||||
|
|
||||||
function renumberPages() {
|
function renumberPages() {
|
||||||
const grid = document.getElementById('page-grid');
|
const grid = document.getElementById('page-grid');
|
||||||
if (!grid) return;
|
if (!grid) return;
|
||||||
const labels = grid.querySelectorAll('.page-number');
|
const labels = grid.querySelectorAll('.page-number');
|
||||||
labels.forEach((label, index) => {
|
labels.forEach((label, index) => {
|
||||||
label.textContent = (index + 1).toString();
|
label.textContent = (index + 1).toString();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function attachEventListeners(element: HTMLElement) {
|
function attachEventListeners(element: HTMLElement) {
|
||||||
const duplicateBtn = element.querySelector('.duplicate-btn');
|
const duplicateBtn = element.querySelector('.duplicate-btn');
|
||||||
const deleteBtn = element.querySelector('.delete-btn');
|
const deleteBtn = element.querySelector('.delete-btn');
|
||||||
|
|
||||||
duplicateBtn?.addEventListener('click', (e) => {
|
duplicateBtn?.addEventListener('click', (e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
const clone = element.cloneNode(true) as HTMLElement;
|
const clone = element.cloneNode(true) as HTMLElement;
|
||||||
element.after(clone);
|
element.after(clone);
|
||||||
attachEventListeners(clone);
|
attachEventListeners(clone);
|
||||||
renumberPages();
|
renumberPages();
|
||||||
createIcons({ icons });
|
createIcons({ icons });
|
||||||
initializeSortable();
|
initializeSortable();
|
||||||
});
|
});
|
||||||
|
|
||||||
deleteBtn?.addEventListener('click', (e) => {
|
deleteBtn?.addEventListener('click', (e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
const grid = document.getElementById('page-grid');
|
const grid = document.getElementById('page-grid');
|
||||||
if (grid && grid.children.length > 1) {
|
if (grid && grid.children.length > 1) {
|
||||||
element.remove();
|
element.remove();
|
||||||
renumberPages();
|
renumberPages();
|
||||||
initializeSortable();
|
initializeSortable();
|
||||||
} else {
|
} else {
|
||||||
showAlert('Cannot Delete', 'You cannot delete the last page of the document.');
|
showAlert(
|
||||||
}
|
'Cannot Delete',
|
||||||
});
|
'You cannot delete the last page of the document.'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async function renderThumbnails() {
|
async function renderThumbnails() {
|
||||||
const grid = document.getElementById('page-grid');
|
const grid = document.getElementById('page-grid');
|
||||||
const processBtn = document.getElementById('process-btn');
|
const processBtn = document.getElementById('process-btn');
|
||||||
const advancedSettings = document.getElementById('advanced-settings');
|
const advancedSettings = document.getElementById('advanced-settings');
|
||||||
if (!grid || !processBtn || !advancedSettings) return;
|
if (!grid || !processBtn || !advancedSettings) return;
|
||||||
|
|
||||||
grid.innerHTML = '';
|
grid.innerHTML = '';
|
||||||
grid.classList.remove('hidden');
|
grid.classList.remove('hidden');
|
||||||
processBtn.classList.remove('hidden');
|
processBtn.classList.remove('hidden');
|
||||||
advancedSettings.classList.remove('hidden');
|
advancedSettings.classList.remove('hidden');
|
||||||
|
|
||||||
for (let i = 1; i <= organizeState.totalPages; i++) {
|
for (let i = 1; i <= organizeState.totalPages; i++) {
|
||||||
const page = await organizeState.pdfJsDoc.getPage(i);
|
const page = await organizeState.pdfJsDoc.getPage(i);
|
||||||
const viewport = page.getViewport({ scale: 0.5 });
|
const viewport = page.getViewport({ scale: 1 });
|
||||||
|
|
||||||
const canvas = document.createElement('canvas');
|
const canvas = document.createElement('canvas');
|
||||||
canvas.width = viewport.width;
|
canvas.width = viewport.width;
|
||||||
canvas.height = viewport.height;
|
canvas.height = viewport.height;
|
||||||
const ctx = canvas.getContext('2d');
|
const ctx = canvas.getContext('2d');
|
||||||
await page.render({ canvasContext: ctx, viewport }).promise;
|
await page.render({ canvasContext: ctx, viewport }).promise;
|
||||||
|
|
||||||
const wrapper = document.createElement('div');
|
const wrapper = document.createElement('div');
|
||||||
wrapper.className = 'page-thumbnail relative cursor-move flex flex-col items-center gap-2';
|
wrapper.className =
|
||||||
wrapper.dataset.originalPageIndex = (i - 1).toString();
|
'page-thumbnail relative cursor-move flex flex-col items-center gap-1 p-2 border-2 border-gray-600 hover:border-indigo-500 rounded-lg bg-gray-700 transition-colors group';
|
||||||
|
wrapper.dataset.originalPageIndex = (i - 1).toString();
|
||||||
|
wrapper.dataset.pageNumber = i.toString();
|
||||||
|
|
||||||
const imgContainer = document.createElement('div');
|
const imgContainer = document.createElement('div');
|
||||||
imgContainer.className = 'w-full h-36 bg-gray-900 rounded-lg flex items-center justify-center overflow-hidden border-2 border-gray-600';
|
imgContainer.className = 'relative';
|
||||||
|
|
||||||
const img = document.createElement('img');
|
const img = document.createElement('img');
|
||||||
img.src = canvas.toDataURL();
|
img.src = canvas.toDataURL();
|
||||||
img.className = 'max-w-full max-h-full object-contain';
|
img.className = 'rounded-md shadow-md max-w-full h-auto';
|
||||||
imgContainer.appendChild(img);
|
imgContainer.appendChild(img);
|
||||||
|
|
||||||
const pageLabel = document.createElement('span');
|
const pageLabel = document.createElement('div');
|
||||||
pageLabel.className = 'page-number absolute top-1 left-1 bg-gray-900 bg-opacity-75 text-white text-xs rounded-full px-2 py-1';
|
pageLabel.className =
|
||||||
pageLabel.textContent = i.toString();
|
'page-number absolute top-1 left-1 bg-indigo-600 text-white text-xs px-2 py-1 rounded-md font-semibold shadow-lg z-10 pointer-events-none';
|
||||||
|
pageLabel.textContent = i.toString();
|
||||||
|
imgContainer.appendChild(pageLabel);
|
||||||
|
|
||||||
const controlsDiv = document.createElement('div');
|
const controlsDiv = document.createElement('div');
|
||||||
controlsDiv.className = 'flex items-center justify-center gap-4';
|
controlsDiv.className = 'flex items-center justify-center gap-4';
|
||||||
|
|
||||||
const duplicateBtn = document.createElement('button');
|
const duplicateBtn = document.createElement('button');
|
||||||
duplicateBtn.className = 'duplicate-btn bg-green-600 hover:bg-green-700 text-white rounded-full w-8 h-8 flex items-center justify-center';
|
duplicateBtn.className =
|
||||||
duplicateBtn.title = 'Duplicate Page';
|
'duplicate-btn bg-green-600 hover:bg-green-700 text-white rounded-full w-8 h-8 flex items-center justify-center';
|
||||||
duplicateBtn.innerHTML = '<i data-lucide="copy-plus" class="w-5 h-5"></i>';
|
duplicateBtn.title = 'Duplicate Page';
|
||||||
|
duplicateBtn.innerHTML = '<i data-lucide="copy-plus" class="w-5 h-5"></i>';
|
||||||
|
|
||||||
const deleteBtn = document.createElement('button');
|
const deleteBtn = document.createElement('button');
|
||||||
deleteBtn.className = 'delete-btn bg-red-600 hover:bg-red-700 text-white rounded-full w-8 h-8 flex items-center justify-center';
|
deleteBtn.className =
|
||||||
deleteBtn.title = 'Delete Page';
|
'delete-btn bg-red-600 hover:bg-red-700 text-white rounded-full w-8 h-8 flex items-center justify-center';
|
||||||
deleteBtn.innerHTML = '<i data-lucide="x-circle" class="w-5 h-5"></i>';
|
deleteBtn.title = 'Delete Page';
|
||||||
|
deleteBtn.innerHTML = '<i data-lucide="x-circle" class="w-5 h-5"></i>';
|
||||||
|
|
||||||
controlsDiv.append(duplicateBtn, deleteBtn);
|
controlsDiv.append(duplicateBtn, deleteBtn);
|
||||||
wrapper.append(imgContainer, pageLabel, controlsDiv);
|
wrapper.append(imgContainer, controlsDiv);
|
||||||
grid.appendChild(wrapper);
|
grid.appendChild(wrapper);
|
||||||
|
|
||||||
attachEventListeners(wrapper);
|
attachEventListeners(wrapper);
|
||||||
}
|
}
|
||||||
|
|
||||||
createIcons({ icons });
|
createIcons({ icons });
|
||||||
initializeSortable();
|
initializeSortable();
|
||||||
|
initPagePreview(grid, organizeState.pdfJsDoc);
|
||||||
}
|
}
|
||||||
|
|
||||||
function initializeSortable() {
|
function initializeSortable() {
|
||||||
const grid = document.getElementById('page-grid');
|
const grid = document.getElementById('page-grid');
|
||||||
if (!grid) return;
|
if (!grid) return;
|
||||||
|
|
||||||
if (organizeState.sortableInstance) organizeState.sortableInstance.destroy();
|
if (organizeState.sortableInstance) organizeState.sortableInstance.destroy();
|
||||||
|
|
||||||
organizeState.sortableInstance = Sortable.create(grid, {
|
organizeState.sortableInstance = Sortable.create(grid, {
|
||||||
animation: 150,
|
animation: 150,
|
||||||
ghostClass: 'sortable-ghost',
|
ghostClass: 'sortable-ghost',
|
||||||
chosenClass: 'sortable-chosen',
|
chosenClass: 'sortable-chosen',
|
||||||
dragClass: 'sortable-drag',
|
dragClass: 'sortable-drag',
|
||||||
filter: '.duplicate-btn, .delete-btn',
|
filter: '.duplicate-btn, .delete-btn',
|
||||||
preventOnFilter: true,
|
preventOnFilter: true,
|
||||||
onStart: (evt) => {
|
onStart: (evt) => {
|
||||||
if (evt.item) evt.item.style.opacity = '0.5';
|
if (evt.item) evt.item.style.opacity = '0.5';
|
||||||
},
|
},
|
||||||
onEnd: (evt) => {
|
onEnd: (evt) => {
|
||||||
if (evt.item) evt.item.style.opacity = '1';
|
if (evt.item) evt.item.style.opacity = '1';
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async function saveChanges() {
|
async function saveChanges() {
|
||||||
showLoader('Building new PDF...');
|
showLoader('Building new PDF...');
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const grid = document.getElementById('page-grid');
|
const grid = document.getElementById('page-grid');
|
||||||
if (!grid) return;
|
if (!grid) return;
|
||||||
|
|
||||||
const finalPageElements = grid.querySelectorAll('.page-thumbnail');
|
const finalPageElements = grid.querySelectorAll('.page-thumbnail');
|
||||||
const finalIndices = Array.from(finalPageElements)
|
const finalIndices = Array.from(finalPageElements)
|
||||||
.map(el => parseInt((el as HTMLElement).dataset.originalPageIndex || '', 10))
|
.map((el) =>
|
||||||
.filter(index => !isNaN(index) && index >= 0);
|
parseInt((el as HTMLElement).dataset.originalPageIndex || '', 10)
|
||||||
|
)
|
||||||
|
.filter((index) => !isNaN(index) && index >= 0);
|
||||||
|
|
||||||
if (finalIndices.length === 0) {
|
if (finalIndices.length === 0) {
|
||||||
showAlert('Error', 'No valid pages to save.');
|
showAlert('Error', 'No valid pages to save.');
|
||||||
return;
|
return;
|
||||||
}
|
|
||||||
|
|
||||||
const newPdf = await PDFDocument.create();
|
|
||||||
const copiedPages = await newPdf.copyPages(organizeState.pdfDoc, finalIndices);
|
|
||||||
copiedPages.forEach(page => newPdf.addPage(page));
|
|
||||||
|
|
||||||
const pdfBytes = await newPdf.save();
|
|
||||||
const baseName = organizeState.file?.name.replace('.pdf', '') || 'document';
|
|
||||||
downloadFile(new Blob([pdfBytes as BlobPart], { type: 'application/pdf' }), `${baseName}_organized.pdf`);
|
|
||||||
|
|
||||||
hideLoader();
|
|
||||||
showAlert('Success', 'PDF organized successfully!', 'success', () => resetState());
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error saving changes:', error);
|
|
||||||
hideLoader();
|
|
||||||
showAlert('Error', 'Failed to save changes.');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const newPdf = await PDFDocument.create();
|
||||||
|
const copiedPages = await newPdf.copyPages(
|
||||||
|
organizeState.pdfDoc,
|
||||||
|
finalIndices
|
||||||
|
);
|
||||||
|
copiedPages.forEach((page) => newPdf.addPage(page));
|
||||||
|
|
||||||
|
const pdfBytes = await newPdf.save();
|
||||||
|
const baseName = organizeState.file?.name.replace('.pdf', '') || 'document';
|
||||||
|
downloadFile(
|
||||||
|
new Blob([pdfBytes as BlobPart], { type: 'application/pdf' }),
|
||||||
|
`${baseName}_organized.pdf`
|
||||||
|
);
|
||||||
|
|
||||||
|
hideLoader();
|
||||||
|
showAlert('Success', 'PDF organized successfully!', 'success', () =>
|
||||||
|
resetState()
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error saving changes:', error);
|
||||||
|
hideLoader();
|
||||||
|
showAlert('Error', 'Failed to save changes.');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function resetState() {
|
function resetState() {
|
||||||
if (organizeState.sortableInstance) {
|
if (organizeState.sortableInstance) {
|
||||||
organizeState.sortableInstance.destroy();
|
organizeState.sortableInstance.destroy();
|
||||||
organizeState.sortableInstance = null;
|
organizeState.sortableInstance = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
organizeState.file = null;
|
organizeState.file = null;
|
||||||
organizeState.pdfDoc = null;
|
organizeState.pdfDoc = null;
|
||||||
organizeState.pdfJsDoc = null;
|
organizeState.pdfJsDoc = null;
|
||||||
organizeState.totalPages = 0;
|
organizeState.totalPages = 0;
|
||||||
|
|
||||||
const grid = document.getElementById('page-grid');
|
const grid = document.getElementById('page-grid');
|
||||||
if (grid) {
|
if (grid) {
|
||||||
grid.innerHTML = '';
|
grid.innerHTML = '';
|
||||||
grid.classList.add('hidden');
|
grid.classList.add('hidden');
|
||||||
}
|
}
|
||||||
document.getElementById('process-btn')?.classList.add('hidden');
|
document.getElementById('process-btn')?.classList.add('hidden');
|
||||||
document.getElementById('advanced-settings')?.classList.add('hidden');
|
document.getElementById('advanced-settings')?.classList.add('hidden');
|
||||||
const fileDisplayArea = document.getElementById('file-display-area');
|
const fileDisplayArea = document.getElementById('file-display-area');
|
||||||
if (fileDisplayArea) fileDisplayArea.innerHTML = '';
|
if (fileDisplayArea) fileDisplayArea.innerHTML = '';
|
||||||
}
|
}
|
||||||
|
|||||||
+536
-431
File diff suppressed because it is too large
Load Diff
+398
-132
@@ -1,10 +1,15 @@
|
|||||||
import { createIcons, icons } from 'lucide';
|
import { createIcons, icons } from 'lucide';
|
||||||
import { degrees, PDFDocument as PDFLibDocument } from 'pdf-lib';
|
import { degrees, PDFDocument as PDFLibDocument, PDFPage } from 'pdf-lib';
|
||||||
import * as pdfjsLib from 'pdfjs-dist';
|
import * as pdfjsLib from 'pdfjs-dist';
|
||||||
import JSZip from 'jszip';
|
import JSZip from 'jszip';
|
||||||
import Sortable from 'sortablejs';
|
import Sortable from 'sortablejs';
|
||||||
import { downloadFile, getPDFDocument } from '../utils/helpers';
|
import { downloadFile, getPDFDocument } from '../utils/helpers';
|
||||||
import { renderPagesProgressively, cleanupLazyRendering, renderPageToCanvas, createPlaceholder } from '../utils/render-utils';
|
import {
|
||||||
|
renderPagesProgressively,
|
||||||
|
cleanupLazyRendering,
|
||||||
|
renderPageToCanvas,
|
||||||
|
createPlaceholder,
|
||||||
|
} from '../utils/render-utils';
|
||||||
import { initializeGlobalShortcuts } from '../utils/shortcuts-init.js';
|
import { initializeGlobalShortcuts } from '../utils/shortcuts-init.js';
|
||||||
import { repairPdfFile } from './repair-pdf.js';
|
import { repairPdfFile } from './repair-pdf.js';
|
||||||
|
|
||||||
@@ -41,13 +46,17 @@ let sortableInstance: Sortable | null = null;
|
|||||||
|
|
||||||
const pageCanvasCache = new Map<string, HTMLCanvasElement>();
|
const pageCanvasCache = new Map<string, HTMLCanvasElement>();
|
||||||
|
|
||||||
type Snapshot = { allPages: PageData[]; selectedPages: number[]; splitMarkers: number[] };
|
type Snapshot = {
|
||||||
|
allPages: PageData[];
|
||||||
|
selectedPages: number[];
|
||||||
|
splitMarkers: number[];
|
||||||
|
};
|
||||||
const undoStack: Snapshot[] = [];
|
const undoStack: Snapshot[] = [];
|
||||||
const redoStack: Snapshot[] = [];
|
const redoStack: Snapshot[] = [];
|
||||||
|
|
||||||
function snapshot() {
|
function snapshot() {
|
||||||
const snap: Snapshot = {
|
const snap: Snapshot = {
|
||||||
allPages: allPages.map(p => ({ ...p, canvas: p.canvas })),
|
allPages: allPages.map((p) => ({ ...p, canvas: p.canvas })),
|
||||||
selectedPages: Array.from(selectedPages),
|
selectedPages: Array.from(selectedPages),
|
||||||
splitMarkers: Array.from(splitMarkers),
|
splitMarkers: Array.from(splitMarkers),
|
||||||
};
|
};
|
||||||
@@ -56,16 +65,20 @@ function snapshot() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function restore(snap: Snapshot) {
|
function restore(snap: Snapshot) {
|
||||||
allPages = snap.allPages.map(p => ({
|
allPages = snap.allPages.map((p) => ({
|
||||||
...p,
|
...p,
|
||||||
canvas: p.canvas
|
canvas: p.canvas,
|
||||||
}));
|
}));
|
||||||
selectedPages = new Set(snap.selectedPages);
|
selectedPages = new Set(snap.selectedPages);
|
||||||
splitMarkers = new Set(snap.splitMarkers);
|
splitMarkers = new Set(snap.splitMarkers);
|
||||||
updatePageDisplay();
|
updatePageDisplay();
|
||||||
}
|
}
|
||||||
|
|
||||||
function showModal(title: string, message: string, type: 'info' | 'error' | 'success' = 'info') {
|
function showModal(
|
||||||
|
title: string,
|
||||||
|
message: string,
|
||||||
|
type: 'info' | 'error' | 'success' = 'info'
|
||||||
|
) {
|
||||||
const modal = document.getElementById('modal');
|
const modal = document.getElementById('modal');
|
||||||
const modalTitle = document.getElementById('modal-title');
|
const modalTitle = document.getElementById('modal-title');
|
||||||
const modalMessage = document.getElementById('modal-message');
|
const modalMessage = document.getElementById('modal-message');
|
||||||
@@ -79,12 +92,12 @@ function showModal(title: string, message: string, type: 'info' | 'error' | 'suc
|
|||||||
const iconMap = {
|
const iconMap = {
|
||||||
info: 'info',
|
info: 'info',
|
||||||
error: 'alert-circle',
|
error: 'alert-circle',
|
||||||
success: 'check-circle'
|
success: 'check-circle',
|
||||||
};
|
};
|
||||||
const colorMap = {
|
const colorMap = {
|
||||||
info: 'text-blue-400',
|
info: 'text-blue-400',
|
||||||
error: 'text-red-400',
|
error: 'text-red-400',
|
||||||
success: 'text-green-400'
|
success: 'text-green-400',
|
||||||
};
|
};
|
||||||
|
|
||||||
modalIcon.innerHTML = `<i data-lucide="${iconMap[type]}" class="w-12 h-12 ${colorMap[type]}"></i>`;
|
modalIcon.innerHTML = `<i data-lucide="${iconMap[type]}" class="w-12 h-12 ${colorMap[type]}"></i>`;
|
||||||
@@ -112,7 +125,10 @@ function showLoading(current: number, total: number) {
|
|||||||
text.textContent = t('multiTool.renderingPages');
|
text.textContent = t('multiTool.renderingPages');
|
||||||
}
|
}
|
||||||
|
|
||||||
async function withButtonLoading(buttonId: string, action: () => Promise<void>) {
|
async function withButtonLoading(
|
||||||
|
buttonId: string,
|
||||||
|
action: () => Promise<void>
|
||||||
|
) {
|
||||||
const button = document.getElementById(buttonId) as HTMLButtonElement;
|
const button = document.getElementById(buttonId) as HTMLButtonElement;
|
||||||
if (!button) return;
|
if (!button) return;
|
||||||
|
|
||||||
@@ -122,7 +138,8 @@ async function withButtonLoading(buttonId: string, action: () => Promise<void>)
|
|||||||
try {
|
try {
|
||||||
button.disabled = true;
|
button.disabled = true;
|
||||||
button.style.pointerEvents = 'none';
|
button.style.pointerEvents = 'none';
|
||||||
button.innerHTML = '<svg class="animate-spin h-5 w-5 text-white" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle><path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path></svg>';
|
button.innerHTML =
|
||||||
|
'<svg class="animate-spin h-5 w-5 text-white" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle><path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path></svg>';
|
||||||
|
|
||||||
await action();
|
await action();
|
||||||
} finally {
|
} finally {
|
||||||
@@ -143,7 +160,9 @@ if (document.readyState === 'loading') {
|
|||||||
initializeTool();
|
initializeTool();
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
console.log('PDF Multi Tool: DOMContentLoaded already fired, initializing immediately');
|
console.log(
|
||||||
|
'PDF Multi Tool: DOMContentLoaded already fired, initializing immediately'
|
||||||
|
);
|
||||||
initializeTool();
|
initializeTool();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -160,20 +179,30 @@ function initializeTool() {
|
|||||||
document.getElementById('upload-pdfs-btn')?.addEventListener('click', () => {
|
document.getElementById('upload-pdfs-btn')?.addEventListener('click', () => {
|
||||||
console.log('Upload button clicked, isRendering:', isRendering);
|
console.log('Upload button clicked, isRendering:', isRendering);
|
||||||
if (isRendering) {
|
if (isRendering) {
|
||||||
showModal(t('multiTool.pleaseWait'), t('multiTool.pagesRendering'), 'info');
|
showModal(
|
||||||
|
t('multiTool.pleaseWait'),
|
||||||
|
t('multiTool.pagesRendering'),
|
||||||
|
'info'
|
||||||
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
document.getElementById('pdf-file-input')?.click();
|
document.getElementById('pdf-file-input')?.click();
|
||||||
});
|
});
|
||||||
|
|
||||||
document.getElementById('pdf-file-input')?.addEventListener('change', handlePdfUpload);
|
document
|
||||||
document.getElementById('insert-pdf-input')?.addEventListener('change', handleInsertPdf);
|
.getElementById('pdf-file-input')
|
||||||
|
?.addEventListener('change', handlePdfUpload);
|
||||||
|
document
|
||||||
|
.getElementById('insert-pdf-input')
|
||||||
|
?.addEventListener('change', handleInsertPdf);
|
||||||
|
|
||||||
document.getElementById('bulk-rotate-left-btn')?.addEventListener('click', () => {
|
document
|
||||||
if (isRendering) return;
|
.getElementById('bulk-rotate-left-btn')
|
||||||
snapshot();
|
?.addEventListener('click', () => {
|
||||||
bulkRotate(-90);
|
if (isRendering) return;
|
||||||
});
|
snapshot();
|
||||||
|
bulkRotate(-90);
|
||||||
|
});
|
||||||
document.getElementById('bulk-rotate-btn')?.addEventListener('click', () => {
|
document.getElementById('bulk-rotate-btn')?.addEventListener('click', () => {
|
||||||
if (isRendering) return;
|
if (isRendering) return;
|
||||||
snapshot();
|
snapshot();
|
||||||
@@ -184,27 +213,38 @@ function initializeTool() {
|
|||||||
snapshot();
|
snapshot();
|
||||||
bulkDelete();
|
bulkDelete();
|
||||||
});
|
});
|
||||||
document.getElementById('bulk-duplicate-btn')?.addEventListener('click', () => {
|
document
|
||||||
if (isRendering) return;
|
.getElementById('bulk-duplicate-btn')
|
||||||
snapshot();
|
?.addEventListener('click', () => {
|
||||||
bulkDuplicate();
|
if (isRendering) return;
|
||||||
});
|
snapshot();
|
||||||
|
bulkDuplicate();
|
||||||
|
});
|
||||||
document.getElementById('bulk-split-btn')?.addEventListener('click', () => {
|
document.getElementById('bulk-split-btn')?.addEventListener('click', () => {
|
||||||
if (isRendering) return;
|
if (isRendering) return;
|
||||||
snapshot();
|
snapshot();
|
||||||
bulkSplit();
|
bulkSplit();
|
||||||
});
|
});
|
||||||
document.getElementById('bulk-download-btn')?.addEventListener('click', () => {
|
document
|
||||||
if (isRendering) return;
|
.getElementById('bulk-download-btn')
|
||||||
if (isRendering) return;
|
?.addEventListener('click', () => {
|
||||||
if (selectedPages.size === 0) {
|
if (isRendering) return;
|
||||||
showModal(t('multiTool.noPagesSelected'), t('multiTool.selectOnePage'), 'info');
|
if (isRendering) return;
|
||||||
return;
|
if (selectedPages.size === 0) {
|
||||||
}
|
showModal(
|
||||||
withButtonLoading('bulk-download-btn', async () => {
|
t('multiTool.noPagesSelected'),
|
||||||
await downloadPagesAsPdf(Array.from(selectedPages).sort((a, b) => a - b), 'selected-pages.pdf');
|
t('multiTool.selectOnePage'),
|
||||||
|
'info'
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
withButtonLoading('bulk-download-btn', async () => {
|
||||||
|
await downloadPagesAsPdf(
|
||||||
|
Array.from(selectedPages).sort((a, b) => a - b),
|
||||||
|
'selected-pages.pdf'
|
||||||
|
);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
|
||||||
|
|
||||||
document.getElementById('select-all-btn')?.addEventListener('click', () => {
|
document.getElementById('select-all-btn')?.addEventListener('click', () => {
|
||||||
if (isRendering) return;
|
if (isRendering) return;
|
||||||
@@ -229,17 +269,19 @@ function initializeTool() {
|
|||||||
await downloadAll();
|
await downloadAll();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
document.getElementById('add-blank-page-btn')?.addEventListener('click', () => {
|
document
|
||||||
if (isRendering) return;
|
.getElementById('add-blank-page-btn')
|
||||||
snapshot();
|
?.addEventListener('click', () => {
|
||||||
addBlankPage();
|
if (isRendering) return;
|
||||||
});
|
snapshot();
|
||||||
|
addBlankPage();
|
||||||
|
});
|
||||||
document.getElementById('undo-btn')?.addEventListener('click', () => {
|
document.getElementById('undo-btn')?.addEventListener('click', () => {
|
||||||
if (isRendering) return;
|
if (isRendering) return;
|
||||||
const last = undoStack.pop();
|
const last = undoStack.pop();
|
||||||
if (last) {
|
if (last) {
|
||||||
const current: Snapshot = {
|
const current: Snapshot = {
|
||||||
allPages: allPages.map(p => ({ ...p })),
|
allPages: allPages.map((p) => ({ ...p })),
|
||||||
selectedPages: Array.from(selectedPages),
|
selectedPages: Array.from(selectedPages),
|
||||||
splitMarkers: Array.from(splitMarkers),
|
splitMarkers: Array.from(splitMarkers),
|
||||||
};
|
};
|
||||||
@@ -252,7 +294,7 @@ function initializeTool() {
|
|||||||
const next = redoStack.pop();
|
const next = redoStack.pop();
|
||||||
if (next) {
|
if (next) {
|
||||||
const current: Snapshot = {
|
const current: Snapshot = {
|
||||||
allPages: allPages.map(p => ({ ...p })),
|
allPages: allPages.map((p) => ({ ...p })),
|
||||||
selectedPages: Array.from(selectedPages),
|
selectedPages: Array.from(selectedPages),
|
||||||
splitMarkers: Array.from(splitMarkers),
|
splitMarkers: Array.from(splitMarkers),
|
||||||
};
|
};
|
||||||
@@ -269,7 +311,9 @@ function initializeTool() {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
document.getElementById('modal-close-btn')?.addEventListener('click', hideModal);
|
document
|
||||||
|
.getElementById('modal-close-btn')
|
||||||
|
?.addEventListener('click', hideModal);
|
||||||
document.getElementById('modal')?.addEventListener('click', (e) => {
|
document.getElementById('modal')?.addEventListener('click', (e) => {
|
||||||
if (e.target === document.getElementById('modal')) {
|
if (e.target === document.getElementById('modal')) {
|
||||||
hideModal();
|
hideModal();
|
||||||
@@ -288,7 +332,9 @@ function initializeTool() {
|
|||||||
uploadArea.addEventListener('drop', (e) => {
|
uploadArea.addEventListener('drop', (e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
uploadArea.classList.remove('border-indigo-500');
|
uploadArea.classList.remove('border-indigo-500');
|
||||||
const files = Array.from(e.dataTransfer?.files || []).filter(f => f.type === 'application/pdf');
|
const files = Array.from(e.dataTransfer?.files || []).filter(
|
||||||
|
(f) => f.type === 'application/pdf'
|
||||||
|
);
|
||||||
if (files.length > 0) {
|
if (files.length > 0) {
|
||||||
loadPdfs(files);
|
loadPdfs(files);
|
||||||
}
|
}
|
||||||
@@ -361,27 +407,38 @@ async function loadPdfs(files: File[]) {
|
|||||||
try {
|
try {
|
||||||
console.log(`Repairing ${file.name}...`);
|
console.log(`Repairing ${file.name}...`);
|
||||||
const loadingText = document.getElementById('loading-text');
|
const loadingText = document.getElementById('loading-text');
|
||||||
if (loadingText) loadingText.textContent = `Repairing ${file.name}...`;
|
if (loadingText)
|
||||||
|
loadingText.textContent = `Repairing ${file.name}...`;
|
||||||
|
|
||||||
const repairedData = await repairPdfFile(file);
|
const repairedData = await repairPdfFile(file);
|
||||||
if (repairedData) {
|
if (repairedData) {
|
||||||
arrayBuffer = repairedData.buffer as ArrayBuffer;
|
arrayBuffer = repairedData.buffer as ArrayBuffer;
|
||||||
console.log(`Successfully repaired ${file.name} before loading.`);
|
console.log(`Successfully repaired ${file.name} before loading.`);
|
||||||
} else {
|
} else {
|
||||||
console.warn(`Repair returned null for ${file.name}, using original file.`);
|
console.warn(
|
||||||
|
`Repair returned null for ${file.name}, using original file.`
|
||||||
|
);
|
||||||
arrayBuffer = await file.arrayBuffer();
|
arrayBuffer = await file.arrayBuffer();
|
||||||
}
|
}
|
||||||
} catch (repairError) {
|
} catch (repairError) {
|
||||||
console.warn(`Failed to repair ${file.name}, attempting to load original:`, repairError);
|
console.warn(
|
||||||
|
`Failed to repair ${file.name}, attempting to load original:`,
|
||||||
|
repairError
|
||||||
|
);
|
||||||
arrayBuffer = await file.arrayBuffer();
|
arrayBuffer = await file.arrayBuffer();
|
||||||
}
|
}
|
||||||
|
|
||||||
const pdfDoc = await PDFLibDocument.load(arrayBuffer, { ignoreEncryption: true, throwOnInvalidObject: false });
|
const pdfDoc = await PDFLibDocument.load(arrayBuffer, {
|
||||||
|
ignoreEncryption: true,
|
||||||
|
throwOnInvalidObject: false,
|
||||||
|
});
|
||||||
currentPdfDocs.push(pdfDoc);
|
currentPdfDocs.push(pdfDoc);
|
||||||
const pdfIndex = currentPdfDocs.length - 1;
|
const pdfIndex = currentPdfDocs.length - 1;
|
||||||
|
|
||||||
const pdfBytes = await pdfDoc.save();
|
const pdfBytes = await pdfDoc.save();
|
||||||
const pdfjsDoc = await getPDFDocument({ data: new Uint8Array(pdfBytes) }).promise;
|
const pdfjsDoc = await getPDFDocument({
|
||||||
|
data: new Uint8Array(pdfBytes),
|
||||||
|
}).promise;
|
||||||
const numPages = pdfjsDoc.numPages;
|
const numPages = pdfjsDoc.numPages;
|
||||||
|
|
||||||
// Pre-fill allPages with placeholders to maintain order/state
|
// Pre-fill allPages with placeholders to maintain order/state
|
||||||
@@ -425,10 +482,13 @@ async function loadPdfs(files: File[]) {
|
|||||||
shouldCancel: () => renderCancelled, // Pass cancellation check
|
shouldCancel: () => renderCancelled, // Pass cancellation check
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error(`Failed to load PDF ${file.name}:`, e);
|
console.error(`Failed to load PDF ${file.name}:`, e);
|
||||||
showModal(t('multiTool.error'), `${t('multiTool.failedToLoad')} ${file.name}.`, 'error');
|
showModal(
|
||||||
|
t('multiTool.error'),
|
||||||
|
`${t('multiTool.failedToLoad')} ${file.name}.`,
|
||||||
|
'error'
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -439,7 +499,9 @@ async function loadPdfs(files: File[]) {
|
|||||||
} finally {
|
} finally {
|
||||||
hideLoading();
|
hideLoading();
|
||||||
isRendering = false;
|
isRendering = false;
|
||||||
console.log('PDF Multi Tool: Render finished/cancelled, isRendering set to false');
|
console.log(
|
||||||
|
'PDF Multi Tool: Render finished/cancelled, isRendering set to false'
|
||||||
|
);
|
||||||
if (renderCancelled) {
|
if (renderCancelled) {
|
||||||
renderCancelled = false;
|
renderCancelled = false;
|
||||||
}
|
}
|
||||||
@@ -464,7 +526,10 @@ function createPageCard(pageData: PageData, index: number) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Modified to return the element instead of appending it
|
// Modified to return the element instead of appending it
|
||||||
function createPageElement(canvas: HTMLCanvasElement | null, index: number): HTMLElement {
|
function createPageElement(
|
||||||
|
canvas: HTMLCanvasElement | null,
|
||||||
|
index: number
|
||||||
|
): HTMLElement {
|
||||||
const pageData = allPages[index];
|
const pageData = allPages[index];
|
||||||
if (!pageData) {
|
if (!pageData) {
|
||||||
console.error(`Page data not found for index ${index}`);
|
console.error(`Page data not found for index ${index}`);
|
||||||
@@ -472,7 +537,8 @@ function createPageElement(canvas: HTMLCanvasElement | null, index: number): HTM
|
|||||||
}
|
}
|
||||||
|
|
||||||
const card = document.createElement('div');
|
const card = document.createElement('div');
|
||||||
card.className = 'bg-gray-800 rounded-lg border-2 border-gray-700 p-2 relative group cursor-move';
|
card.className =
|
||||||
|
'bg-gray-800 rounded-lg border-2 border-gray-700 p-2 relative group cursor-move';
|
||||||
card.dataset.pageIndex = index.toString();
|
card.dataset.pageIndex = index.toString();
|
||||||
card.dataset.pageId = pageData.id; // Set ID for reconciliation
|
card.dataset.pageId = pageData.id; // Set ID for reconciliation
|
||||||
|
|
||||||
@@ -490,7 +556,8 @@ function createPageElement(canvas: HTMLCanvasElement | null, index: number): HTM
|
|||||||
}
|
}
|
||||||
|
|
||||||
const preview = document.createElement('div');
|
const preview = document.createElement('div');
|
||||||
preview.className = 'bg-white rounded mb-2 overflow-hidden w-full flex items-center justify-center relative h-36 sm:h-64';
|
preview.className =
|
||||||
|
'bg-white rounded mb-2 overflow-hidden w-full flex items-center justify-center relative h-36 sm:h-64';
|
||||||
|
|
||||||
if (canvas) {
|
if (canvas) {
|
||||||
const previewCanvas = canvas;
|
const previewCanvas = canvas;
|
||||||
@@ -502,7 +569,8 @@ function createPageElement(canvas: HTMLCanvasElement | null, index: number): HTM
|
|||||||
} else {
|
} else {
|
||||||
// Show loading placeholder if canvas is null
|
// Show loading placeholder if canvas is null
|
||||||
const loading = document.createElement('div');
|
const loading = document.createElement('div');
|
||||||
loading.className = 'flex flex-col items-center justify-center text-gray-400';
|
loading.className =
|
||||||
|
'flex flex-col items-center justify-center text-gray-400';
|
||||||
loading.innerHTML = `
|
loading.innerHTML = `
|
||||||
<i data-lucide="loader" class="w-8 h-8 animate-spin mb-2"></i>
|
<i data-lucide="loader" class="w-8 h-8 animate-spin mb-2"></i>
|
||||||
<span class="text-xs">${t('common.loading')}</span>
|
<span class="text-xs">${t('common.loading')}</span>
|
||||||
@@ -518,15 +586,18 @@ function createPageElement(canvas: HTMLCanvasElement | null, index: number): HTM
|
|||||||
|
|
||||||
// Actions toolbar
|
// Actions toolbar
|
||||||
const actions = document.createElement('div');
|
const actions = document.createElement('div');
|
||||||
actions.className = 'flex items-center justify-center gap-1 sm:opacity-0 group-hover:opacity-100 transition-opacity absolute bottom-2 left-0 right-0';
|
actions.className =
|
||||||
|
'flex items-center justify-center gap-1 sm:opacity-0 group-hover:opacity-100 transition-opacity absolute bottom-2 left-0 right-0';
|
||||||
|
|
||||||
const actionsInner = document.createElement('div');
|
const actionsInner = document.createElement('div');
|
||||||
actionsInner.className = 'flex items-center gap-1 bg-gray-900/90 rounded px-2 py-1';
|
actionsInner.className =
|
||||||
|
'flex items-center gap-1 bg-gray-900/90 rounded px-2 py-1';
|
||||||
actions.appendChild(actionsInner);
|
actions.appendChild(actionsInner);
|
||||||
|
|
||||||
// Select checkbox
|
// Select checkbox
|
||||||
const selectBtn = document.createElement('button');
|
const selectBtn = document.createElement('button');
|
||||||
selectBtn.className = 'absolute top-2 right-2 p-1 rounded bg-gray-900/70 hover:bg-gray-800 z-10';
|
selectBtn.className =
|
||||||
|
'absolute top-2 right-2 p-1 rounded bg-gray-900/70 hover:bg-gray-800 z-10';
|
||||||
selectBtn.innerHTML = selectedPages.has(index)
|
selectBtn.innerHTML = selectedPages.has(index)
|
||||||
? '<i data-lucide="check-square" class="w-4 h-4 text-indigo-400"></i>'
|
? '<i data-lucide="check-square" class="w-4 h-4 text-indigo-400"></i>'
|
||||||
: '<i data-lucide="square" class="w-4 h-4 text-gray-200"></i>';
|
: '<i data-lucide="square" class="w-4 h-4 text-gray-200"></i>';
|
||||||
@@ -538,14 +609,16 @@ function createPageElement(canvas: HTMLCanvasElement | null, index: number): HTM
|
|||||||
// Rotate button
|
// Rotate button
|
||||||
const rotateBtn = document.createElement('button');
|
const rotateBtn = document.createElement('button');
|
||||||
rotateBtn.className = 'p-1 rounded hover:bg-gray-700';
|
rotateBtn.className = 'p-1 rounded hover:bg-gray-700';
|
||||||
rotateBtn.innerHTML = '<i data-lucide="rotate-cw" class="w-4 h-4 text-gray-300"></i>';
|
rotateBtn.innerHTML =
|
||||||
|
'<i data-lucide="rotate-cw" class="w-4 h-4 text-gray-300"></i>';
|
||||||
rotateBtn.onclick = (e) => {
|
rotateBtn.onclick = (e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
rotatePage(index, 90);
|
rotatePage(index, 90);
|
||||||
};
|
};
|
||||||
const rotateLeftBtn = document.createElement('button');
|
const rotateLeftBtn = document.createElement('button');
|
||||||
rotateLeftBtn.className = 'p-1 rounded hover:bg-gray-700';
|
rotateLeftBtn.className = 'p-1 rounded hover:bg-gray-700';
|
||||||
rotateLeftBtn.innerHTML = '<i data-lucide="rotate-ccw" class="w-4 h-4 text-gray-300"></i>';
|
rotateLeftBtn.innerHTML =
|
||||||
|
'<i data-lucide="rotate-ccw" class="w-4 h-4 text-gray-300"></i>';
|
||||||
rotateLeftBtn.onclick = (e) => {
|
rotateLeftBtn.onclick = (e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
rotatePage(index, -90);
|
rotatePage(index, -90);
|
||||||
@@ -554,7 +627,8 @@ function createPageElement(canvas: HTMLCanvasElement | null, index: number): HTM
|
|||||||
// Duplicate button
|
// Duplicate button
|
||||||
const duplicateBtn = document.createElement('button');
|
const duplicateBtn = document.createElement('button');
|
||||||
duplicateBtn.className = 'p-1 rounded hover:bg-gray-700';
|
duplicateBtn.className = 'p-1 rounded hover:bg-gray-700';
|
||||||
duplicateBtn.innerHTML = '<i data-lucide="copy" class="w-4 h-4 text-gray-300"></i>';
|
duplicateBtn.innerHTML =
|
||||||
|
'<i data-lucide="copy" class="w-4 h-4 text-gray-300"></i>';
|
||||||
duplicateBtn.title = t('multiTool.actions.duplicatePage');
|
duplicateBtn.title = t('multiTool.actions.duplicatePage');
|
||||||
duplicateBtn.onclick = (e) => {
|
duplicateBtn.onclick = (e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
@@ -565,7 +639,8 @@ function createPageElement(canvas: HTMLCanvasElement | null, index: number): HTM
|
|||||||
// Delete button
|
// Delete button
|
||||||
const deleteBtn = document.createElement('button');
|
const deleteBtn = document.createElement('button');
|
||||||
deleteBtn.className = 'p-1 rounded hover:bg-gray-700';
|
deleteBtn.className = 'p-1 rounded hover:bg-gray-700';
|
||||||
deleteBtn.innerHTML = '<i data-lucide="trash-2" class="w-4 h-4 text-red-400"></i>';
|
deleteBtn.innerHTML =
|
||||||
|
'<i data-lucide="trash-2" class="w-4 h-4 text-red-400"></i>';
|
||||||
deleteBtn.title = t('multiTool.actions.deletePage');
|
deleteBtn.title = t('multiTool.actions.deletePage');
|
||||||
deleteBtn.onclick = (e) => {
|
deleteBtn.onclick = (e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
@@ -576,7 +651,8 @@ function createPageElement(canvas: HTMLCanvasElement | null, index: number): HTM
|
|||||||
// Insert PDF button
|
// Insert PDF button
|
||||||
const insertBtn = document.createElement('button');
|
const insertBtn = document.createElement('button');
|
||||||
insertBtn.className = 'p-1 rounded hover:bg-gray-700';
|
insertBtn.className = 'p-1 rounded hover:bg-gray-700';
|
||||||
insertBtn.innerHTML = '<i data-lucide="file-plus" class="w-4 h-4 text-gray-300"></i>';
|
insertBtn.innerHTML =
|
||||||
|
'<i data-lucide="file-plus" class="w-4 h-4 text-gray-300"></i>';
|
||||||
insertBtn.title = t('multiTool.actions.insertPdf');
|
insertBtn.title = t('multiTool.actions.insertPdf');
|
||||||
insertBtn.onclick = (e) => {
|
insertBtn.onclick = (e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
@@ -587,7 +663,8 @@ function createPageElement(canvas: HTMLCanvasElement | null, index: number): HTM
|
|||||||
// Split button
|
// Split button
|
||||||
const splitBtn = document.createElement('button');
|
const splitBtn = document.createElement('button');
|
||||||
splitBtn.className = 'p-1 rounded hover:bg-gray-700';
|
splitBtn.className = 'p-1 rounded hover:bg-gray-700';
|
||||||
splitBtn.innerHTML = '<i data-lucide="scissors" class="w-4 h-4 text-gray-300"></i>';
|
splitBtn.innerHTML =
|
||||||
|
'<i data-lucide="scissors" class="w-4 h-4 text-gray-300"></i>';
|
||||||
splitBtn.title = t('multiTool.actions.toggleSplit');
|
splitBtn.title = t('multiTool.actions.toggleSplit');
|
||||||
splitBtn.onclick = (e) => {
|
splitBtn.onclick = (e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
@@ -596,14 +673,23 @@ function createPageElement(canvas: HTMLCanvasElement | null, index: number): HTM
|
|||||||
renderSplitMarkers();
|
renderSplitMarkers();
|
||||||
};
|
};
|
||||||
|
|
||||||
actionsInner.append(rotateLeftBtn, rotateBtn, duplicateBtn, insertBtn, splitBtn, deleteBtn);
|
actionsInner.append(
|
||||||
|
rotateLeftBtn,
|
||||||
|
rotateBtn,
|
||||||
|
duplicateBtn,
|
||||||
|
insertBtn,
|
||||||
|
splitBtn,
|
||||||
|
deleteBtn
|
||||||
|
);
|
||||||
card.append(preview, info, actions, selectBtn);
|
card.append(preview, info, actions, selectBtn);
|
||||||
|
|
||||||
// Check for split marker
|
// Check for split marker
|
||||||
if (splitMarkers.has(index)) {
|
if (splitMarkers.has(index)) {
|
||||||
const marker = document.createElement('div');
|
const marker = document.createElement('div');
|
||||||
marker.className = 'split-marker absolute -right-3 top-0 bottom-0 w-6 flex items-center justify-center z-20 pointer-events-none';
|
marker.className =
|
||||||
marker.innerHTML = '<div class="h-full w-0.5 border-l-2 border-dashed border-blue-400"></div>';
|
'split-marker absolute -right-3 top-0 bottom-0 w-6 flex items-center justify-center z-20 pointer-events-none';
|
||||||
|
marker.innerHTML =
|
||||||
|
'<div class="h-full w-0.5 border-l-2 border-dashed border-blue-400"></div>';
|
||||||
card.appendChild(marker);
|
card.appendChild(marker);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -655,15 +741,19 @@ function toggleSelectOptimized(index: number) {
|
|||||||
const card = pagesContainer.children[index] as HTMLElement;
|
const card = pagesContainer.children[index] as HTMLElement;
|
||||||
if (!card) return;
|
if (!card) return;
|
||||||
|
|
||||||
const selectBtn = card.querySelector('button[class*="absolute top-2 right-2"]');
|
const selectBtn = card.querySelector(
|
||||||
|
'button[class*="absolute top-2 right-2"]'
|
||||||
|
);
|
||||||
if (!selectBtn) return;
|
if (!selectBtn) return;
|
||||||
|
|
||||||
if (selectedPages.has(index)) {
|
if (selectedPages.has(index)) {
|
||||||
card.classList.add('border-indigo-500', 'ring-2', 'ring-indigo-500');
|
card.classList.add('border-indigo-500', 'ring-2', 'ring-indigo-500');
|
||||||
selectBtn.innerHTML = '<i data-lucide="check-square" class="w-4 h-4 text-indigo-400"></i>';
|
selectBtn.innerHTML =
|
||||||
|
'<i data-lucide="check-square" class="w-4 h-4 text-indigo-400"></i>';
|
||||||
} else {
|
} else {
|
||||||
card.classList.remove('border-indigo-500', 'ring-2', 'ring-indigo-500');
|
card.classList.remove('border-indigo-500', 'ring-2', 'ring-indigo-500');
|
||||||
selectBtn.innerHTML = '<i data-lucide="square" class="w-4 h-4 text-gray-200"></i>';
|
selectBtn.innerHTML =
|
||||||
|
'<i data-lucide="square" class="w-4 h-4 text-gray-200"></i>';
|
||||||
}
|
}
|
||||||
|
|
||||||
createIcons({ icons });
|
createIcons({ icons });
|
||||||
@@ -730,7 +820,7 @@ function deletePage(index: number) {
|
|||||||
allPages.splice(index, 1);
|
allPages.splice(index, 1);
|
||||||
selectedPages.delete(index);
|
selectedPages.delete(index);
|
||||||
const newSelected = new Set<number>();
|
const newSelected = new Set<number>();
|
||||||
selectedPages.forEach(i => {
|
selectedPages.forEach((i) => {
|
||||||
if (i > index) newSelected.add(i - 1);
|
if (i > index) newSelected.add(i - 1);
|
||||||
else if (i < index) newSelected.add(i);
|
else if (i < index) newSelected.add(i);
|
||||||
});
|
});
|
||||||
@@ -759,13 +849,17 @@ async function handleInsertPdf(e: Event) {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const arrayBuffer = await file.arrayBuffer();
|
const arrayBuffer = await file.arrayBuffer();
|
||||||
const pdfDoc = await PDFLibDocument.load(arrayBuffer, { ignoreEncryption: true, throwOnInvalidObject: false });
|
const pdfDoc = await PDFLibDocument.load(arrayBuffer, {
|
||||||
|
ignoreEncryption: true,
|
||||||
|
throwOnInvalidObject: false,
|
||||||
|
});
|
||||||
currentPdfDocs.push(pdfDoc);
|
currentPdfDocs.push(pdfDoc);
|
||||||
const pdfIndex = currentPdfDocs.length - 1;
|
const pdfIndex = currentPdfDocs.length - 1;
|
||||||
|
|
||||||
// Load PDF.js document for rendering
|
// Load PDF.js document for rendering
|
||||||
const pdfBytes = await pdfDoc.save();
|
const pdfBytes = await pdfDoc.save();
|
||||||
const pdfjsDoc = await getPDFDocument({ data: new Uint8Array(pdfBytes) }).promise;
|
const pdfjsDoc = await getPDFDocument({ data: new Uint8Array(pdfBytes) })
|
||||||
|
.promise;
|
||||||
const numPages = pdfjsDoc.numPages;
|
const numPages = pdfjsDoc.numPages;
|
||||||
|
|
||||||
const newPages: PageData[] = [];
|
const newPages: PageData[] = [];
|
||||||
@@ -802,13 +896,18 @@ async function handleInsertPdf(e: Event) {
|
|||||||
|
|
||||||
// Update UI if card exists
|
// Update UI if card exists
|
||||||
const pagesContainer = document.getElementById('pages-container');
|
const pagesContainer = document.getElementById('pages-container');
|
||||||
const card = pagesContainer?.querySelector(`div[data-page-index="${globalIndex}"]`);
|
const card = pagesContainer?.querySelector(
|
||||||
|
`div[data-page-index="${globalIndex}"]`
|
||||||
|
);
|
||||||
if (card) {
|
if (card) {
|
||||||
const preview = card.querySelector('.bg-gray-700') || card.querySelector('.bg-white');
|
const preview =
|
||||||
|
card.querySelector('.bg-gray-700') ||
|
||||||
|
card.querySelector('.bg-white');
|
||||||
if (preview) {
|
if (preview) {
|
||||||
// Re-create the preview content
|
// Re-create the preview content
|
||||||
preview.innerHTML = '';
|
preview.innerHTML = '';
|
||||||
preview.className = 'bg-white rounded mb-2 overflow-hidden w-full flex items-center justify-center relative h-36 sm:h-64';
|
preview.className =
|
||||||
|
'bg-white rounded mb-2 overflow-hidden w-full flex items-center justify-center relative h-36 sm:h-64';
|
||||||
|
|
||||||
const previewCanvas = canvas;
|
const previewCanvas = canvas;
|
||||||
previewCanvas.className = 'max-w-full max-h-full object-contain';
|
previewCanvas.className = 'max-w-full max-h-full object-contain';
|
||||||
@@ -819,10 +918,13 @@ async function handleInsertPdf(e: Event) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('Failed to insert PDF:', e);
|
console.error('Failed to insert PDF:', e);
|
||||||
showModal('Error', 'Failed to insert PDF. The file may be corrupted.', 'error');
|
showModal(
|
||||||
|
'Error',
|
||||||
|
'Failed to insert PDF. The file may be corrupted.',
|
||||||
|
'error'
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
input.value = '';
|
input.value = '';
|
||||||
@@ -837,13 +939,15 @@ function renderSplitMarkers() {
|
|||||||
const pagesContainer = document.getElementById('pages-container');
|
const pagesContainer = document.getElementById('pages-container');
|
||||||
if (!pagesContainer) return;
|
if (!pagesContainer) return;
|
||||||
|
|
||||||
pagesContainer.querySelectorAll('.split-marker').forEach(m => m.remove());
|
pagesContainer.querySelectorAll('.split-marker').forEach((m) => m.remove());
|
||||||
|
|
||||||
Array.from(pagesContainer.children).forEach((cardEl, i) => {
|
Array.from(pagesContainer.children).forEach((cardEl, i) => {
|
||||||
if (splitMarkers.has(i)) {
|
if (splitMarkers.has(i)) {
|
||||||
const marker = document.createElement('div');
|
const marker = document.createElement('div');
|
||||||
marker.className = 'split-marker absolute -right-3 top-0 bottom-0 w-6 flex items-center justify-center z-20 pointer-events-none';
|
marker.className =
|
||||||
marker.innerHTML = '<div class="h-full w-0.5 border-l-2 border-dashed border-blue-400"></div>';
|
'split-marker absolute -right-3 top-0 bottom-0 w-6 flex items-center justify-center z-20 pointer-events-none';
|
||||||
|
marker.innerHTML =
|
||||||
|
'<div class="h-full w-0.5 border-l-2 border-dashed border-blue-400"></div>';
|
||||||
(cardEl as HTMLElement).appendChild(marker);
|
(cardEl as HTMLElement).appendChild(marker);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -881,7 +985,7 @@ function bulkRotate(delta: number) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
selectedPages.forEach(index => {
|
selectedPages.forEach((index) => {
|
||||||
const pageData = allPages[index];
|
const pageData = allPages[index];
|
||||||
if (pageData) {
|
if (pageData) {
|
||||||
// Update state
|
// Update state
|
||||||
@@ -890,13 +994,15 @@ function bulkRotate(delta: number) {
|
|||||||
|
|
||||||
// Update DOM immediately if it exists
|
// Update DOM immediately if it exists
|
||||||
const pagesContainer = document.getElementById('pages-container');
|
const pagesContainer = document.getElementById('pages-container');
|
||||||
const card = pagesContainer?.querySelector(`div[data-page-index="${index}"]`);
|
const card = pagesContainer?.querySelector(
|
||||||
|
`div[data-page-index="${index}"]`
|
||||||
|
);
|
||||||
if (card) {
|
if (card) {
|
||||||
const canvas = card.querySelector('canvas');
|
const canvas = card.querySelector('canvas');
|
||||||
if (canvas) {
|
if (canvas) {
|
||||||
canvas.style.transform = `rotate(${pageData.visualRotation}deg)`;
|
canvas.style.transform = `rotate(${pageData.visualRotation}deg)`;
|
||||||
}
|
}
|
||||||
// If no canvas (placeholder), the state update is enough.
|
// If no canvas (placeholder), the state update is enough.
|
||||||
// When it eventually renders, createPageElement will use the new rotation.
|
// When it eventually renders, createPageElement will use the new rotation.
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -911,7 +1017,7 @@ function bulkDelete() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const indices = Array.from(selectedPages).sort((a, b) => b - a);
|
const indices = Array.from(selectedPages).sort((a, b) => b - a);
|
||||||
indices.forEach(index => allPages.splice(index, 1));
|
indices.forEach((index) => allPages.splice(index, 1));
|
||||||
selectedPages.clear();
|
selectedPages.clear();
|
||||||
|
|
||||||
if (allPages.length === 0) {
|
if (allPages.length === 0) {
|
||||||
@@ -928,7 +1034,7 @@ function bulkDuplicate() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const indices = Array.from(selectedPages).sort((a, b) => b - a);
|
const indices = Array.from(selectedPages).sort((a, b) => b - a);
|
||||||
indices.forEach(index => {
|
indices.forEach((index) => {
|
||||||
duplicatePage(index);
|
duplicatePage(index);
|
||||||
});
|
});
|
||||||
selectedPages.clear();
|
selectedPages.clear();
|
||||||
@@ -937,11 +1043,15 @@ function bulkDuplicate() {
|
|||||||
|
|
||||||
function bulkSplit() {
|
function bulkSplit() {
|
||||||
if (selectedPages.size === 0) {
|
if (selectedPages.size === 0) {
|
||||||
showModal('No Selection', 'Please select pages to mark for splitting.', 'info');
|
showModal(
|
||||||
|
'No Selection',
|
||||||
|
'Please select pages to mark for splitting.',
|
||||||
|
'info'
|
||||||
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const indices = Array.from(selectedPages);
|
const indices = Array.from(selectedPages);
|
||||||
indices.forEach(index => {
|
indices.forEach((index) => {
|
||||||
if (!splitMarkers.has(index)) {
|
if (!splitMarkers.has(index)) {
|
||||||
splitMarkers.add(index);
|
splitMarkers.add(index);
|
||||||
}
|
}
|
||||||
@@ -951,7 +1061,6 @@ function bulkSplit() {
|
|||||||
updatePageDisplay();
|
updatePageDisplay();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
async function downloadAll() {
|
async function downloadAll() {
|
||||||
if (allPages.length === 0) {
|
if (allPages.length === 0) {
|
||||||
showModal('No Pages', 'Please upload PDFs first.', 'info');
|
showModal('No Pages', 'Please upload PDFs first.', 'info');
|
||||||
@@ -993,24 +1102,63 @@ async function downloadSplitPdfs() {
|
|||||||
segments.push(currentSegment);
|
segments.push(currentSegment);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create PDFs for each segment
|
|
||||||
for (let segIndex = 0; segIndex < segments.length; segIndex++) {
|
for (let segIndex = 0; segIndex < segments.length; segIndex++) {
|
||||||
const segment = segments[segIndex];
|
const segment = segments[segIndex];
|
||||||
const newPdf = await PDFLibDocument.create();
|
const newPdf = await PDFLibDocument.create();
|
||||||
|
|
||||||
|
const segSpecs: (
|
||||||
|
| {
|
||||||
|
type: 'pdf';
|
||||||
|
pdfDoc: PDFLibDocument;
|
||||||
|
originalPageIndex: number;
|
||||||
|
rotation: number;
|
||||||
|
}
|
||||||
|
| { type: 'blank' }
|
||||||
|
)[] = [];
|
||||||
for (const index of segment) {
|
for (const index of segment) {
|
||||||
const pageData = allPages[index];
|
const pageData = allPages[index];
|
||||||
if (!pageData) {
|
if (!pageData) continue;
|
||||||
console.warn(`Page data missing for index ${index}`);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (pageData.pdfDoc && pageData.originalPageIndex >= 0) {
|
if (pageData.pdfDoc && pageData.originalPageIndex >= 0) {
|
||||||
const [copiedPage] = await newPdf.copyPages(pageData.pdfDoc, [pageData.originalPageIndex]);
|
segSpecs.push({
|
||||||
const page = newPdf.addPage(copiedPage);
|
type: 'pdf',
|
||||||
|
pdfDoc: pageData.pdfDoc,
|
||||||
|
originalPageIndex: pageData.originalPageIndex,
|
||||||
|
rotation: pageData.rotation,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
segSpecs.push({ type: 'blank' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (pageData.rotation !== 0) {
|
const docPageIndices = new Map<PDFLibDocument, number[]>();
|
||||||
|
for (const spec of segSpecs) {
|
||||||
|
if (spec.type === 'pdf') {
|
||||||
|
if (!docPageIndices.has(spec.pdfDoc)) {
|
||||||
|
docPageIndices.set(spec.pdfDoc, []);
|
||||||
|
}
|
||||||
|
docPageIndices.get(spec.pdfDoc)!.push(spec.originalPageIndex);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const copiedPagesMap = new Map<PDFLibDocument, PDFPage[]>();
|
||||||
|
for (const [doc, pageIdxs] of Array.from(docPageIndices)) {
|
||||||
|
const copied = await newPdf.copyPages(doc, pageIdxs);
|
||||||
|
copiedPagesMap.set(doc, copied);
|
||||||
|
}
|
||||||
|
|
||||||
|
const docConsumeIndex = new Map<PDFLibDocument, number>();
|
||||||
|
docPageIndices.forEach((_, doc) => docConsumeIndex.set(doc, 0));
|
||||||
|
|
||||||
|
for (const spec of segSpecs) {
|
||||||
|
if (spec.type === 'pdf') {
|
||||||
|
const idx = docConsumeIndex.get(spec.pdfDoc)!;
|
||||||
|
const copiedPage = copiedPagesMap.get(spec.pdfDoc)![idx];
|
||||||
|
docConsumeIndex.set(spec.pdfDoc, idx + 1);
|
||||||
|
|
||||||
|
const page = newPdf.addPage(copiedPage);
|
||||||
|
if (spec.rotation !== 0) {
|
||||||
const currentRotation = page.getRotation().angle;
|
const currentRotation = page.getRotation().angle;
|
||||||
page.setRotation(degrees(currentRotation + pageData.rotation));
|
page.setRotation(degrees(currentRotation + spec.rotation));
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
newPdf.addPage([595, 842]);
|
newPdf.addPage([595, 842]);
|
||||||
@@ -1025,7 +1173,11 @@ async function downloadSplitPdfs() {
|
|||||||
const zipBlob = await zip.generateAsync({ type: 'blob' });
|
const zipBlob = await zip.generateAsync({ type: 'blob' });
|
||||||
downloadFile(zipBlob, 'split-documents.zip');
|
downloadFile(zipBlob, 'split-documents.zip');
|
||||||
|
|
||||||
showModal('Success', `Downloaded ${segments.length} PDF files in a ZIP archive.`, 'success');
|
showModal(
|
||||||
|
'Success',
|
||||||
|
`Downloaded ${segments.length} PDF files in a ZIP archive.`,
|
||||||
|
'success'
|
||||||
|
);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('Failed to create split PDFs:', e);
|
console.error('Failed to create split PDFs:', e);
|
||||||
showModal('Error', 'Failed to create split PDFs.', 'error');
|
showModal('Error', 'Failed to create split PDFs.', 'error');
|
||||||
@@ -1038,20 +1190,59 @@ async function downloadPagesAsPdf(indices: number[], filename: string) {
|
|||||||
try {
|
try {
|
||||||
const newPdf = await PDFLibDocument.create();
|
const newPdf = await PDFLibDocument.create();
|
||||||
|
|
||||||
|
const pageSpecs: (
|
||||||
|
| {
|
||||||
|
type: 'pdf';
|
||||||
|
pdfDoc: PDFLibDocument;
|
||||||
|
originalPageIndex: number;
|
||||||
|
rotation: number;
|
||||||
|
}
|
||||||
|
| { type: 'blank' }
|
||||||
|
)[] = [];
|
||||||
for (const index of indices) {
|
for (const index of indices) {
|
||||||
const pageData = allPages[index];
|
const pageData = allPages[index];
|
||||||
if (!pageData) {
|
if (!pageData) continue;
|
||||||
console.warn(`Page data missing for index ${index}`);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (pageData.pdfDoc && pageData.originalPageIndex >= 0) {
|
if (pageData.pdfDoc && pageData.originalPageIndex >= 0) {
|
||||||
// Copy page from original PDF
|
pageSpecs.push({
|
||||||
const [copiedPage] = await newPdf.copyPages(pageData.pdfDoc, [pageData.originalPageIndex]);
|
type: 'pdf',
|
||||||
const page = newPdf.addPage(copiedPage);
|
pdfDoc: pageData.pdfDoc,
|
||||||
|
originalPageIndex: pageData.originalPageIndex,
|
||||||
|
rotation: pageData.rotation,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
pageSpecs.push({ type: 'blank' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (pageData.rotation !== 0) {
|
const docPageIndices = new Map<PDFLibDocument, number[]>();
|
||||||
|
for (const spec of pageSpecs) {
|
||||||
|
if (spec.type === 'pdf') {
|
||||||
|
if (!docPageIndices.has(spec.pdfDoc)) {
|
||||||
|
docPageIndices.set(spec.pdfDoc, []);
|
||||||
|
}
|
||||||
|
docPageIndices.get(spec.pdfDoc)!.push(spec.originalPageIndex);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const copiedPagesMap = new Map<PDFLibDocument, PDFPage[]>();
|
||||||
|
for (const [doc, pageIdxs] of Array.from(docPageIndices)) {
|
||||||
|
const copied = await newPdf.copyPages(doc, pageIdxs);
|
||||||
|
copiedPagesMap.set(doc, copied);
|
||||||
|
}
|
||||||
|
|
||||||
|
const docConsumeIndex = new Map<PDFLibDocument, number>();
|
||||||
|
docPageIndices.forEach((_, doc) => docConsumeIndex.set(doc, 0));
|
||||||
|
|
||||||
|
for (const spec of pageSpecs) {
|
||||||
|
if (spec.type === 'pdf') {
|
||||||
|
const idx = docConsumeIndex.get(spec.pdfDoc)!;
|
||||||
|
const copiedPage = copiedPagesMap.get(spec.pdfDoc)![idx];
|
||||||
|
docConsumeIndex.set(spec.pdfDoc, idx + 1);
|
||||||
|
|
||||||
|
const page = newPdf.addPage(copiedPage);
|
||||||
|
if (spec.rotation !== 0) {
|
||||||
const currentRotation = page.getRotation().angle;
|
const currentRotation = page.getRotation().angle;
|
||||||
page.setRotation(degrees(currentRotation + pageData.rotation));
|
page.setRotation(degrees(currentRotation + spec.rotation));
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
newPdf.addPage([595, 842]);
|
newPdf.addPage([595, 842]);
|
||||||
@@ -1059,7 +1250,9 @@ async function downloadPagesAsPdf(indices: number[], filename: string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const pdfBytes = await newPdf.save();
|
const pdfBytes = await newPdf.save();
|
||||||
const blob = new Blob([new Uint8Array(pdfBytes)], { type: 'application/pdf' });
|
const blob = new Blob([new Uint8Array(pdfBytes)], {
|
||||||
|
type: 'application/pdf',
|
||||||
|
});
|
||||||
|
|
||||||
downloadFile(blob, filename);
|
downloadFile(blob, filename);
|
||||||
showModal('Success', 'PDF downloaded successfully.', 'success');
|
showModal('Success', 'PDF downloaded successfully.', 'success');
|
||||||
@@ -1101,18 +1294,28 @@ function updatePageDisplay() {
|
|||||||
|
|
||||||
// Update index-dependent attributes
|
// Update index-dependent attributes
|
||||||
card.dataset.pageIndex = index.toString();
|
card.dataset.pageIndex = index.toString();
|
||||||
const info = card.querySelector('.text-xs.text-gray-400.text-center.mb-2');
|
const info = card.querySelector(
|
||||||
|
'.text-xs.text-gray-400.text-center.mb-2'
|
||||||
|
);
|
||||||
if (info) info.textContent = `Page ${index + 1} `;
|
if (info) info.textContent = `Page ${index + 1} `;
|
||||||
|
|
||||||
// Update selection state
|
// Update selection state
|
||||||
const selectBtn = card.querySelector('button[class*="absolute top-2 right-2"]');
|
const selectBtn = card.querySelector(
|
||||||
|
'button[class*="absolute top-2 right-2"]'
|
||||||
|
);
|
||||||
if (selectBtn) {
|
if (selectBtn) {
|
||||||
if (selectedPages.has(index)) {
|
if (selectedPages.has(index)) {
|
||||||
card.classList.add('border-indigo-500', 'ring-2', 'ring-indigo-500');
|
card.classList.add('border-indigo-500', 'ring-2', 'ring-indigo-500');
|
||||||
selectBtn.innerHTML = '<i data-lucide="check-square" class="w-4 h-4 text-indigo-400"></i>';
|
selectBtn.innerHTML =
|
||||||
|
'<i data-lucide="check-square" class="w-4 h-4 text-indigo-400"></i>';
|
||||||
} else {
|
} else {
|
||||||
card.classList.remove('border-indigo-500', 'ring-2', 'ring-indigo-500');
|
card.classList.remove(
|
||||||
selectBtn.innerHTML = '<i data-lucide="square" class="w-4 h-4 text-gray-200"></i>';
|
'border-indigo-500',
|
||||||
|
'ring-2',
|
||||||
|
'ring-indigo-500'
|
||||||
|
);
|
||||||
|
selectBtn.innerHTML =
|
||||||
|
'<i data-lucide="square" class="w-4 h-4 text-gray-200"></i>';
|
||||||
}
|
}
|
||||||
// Update click handler to use new index
|
// Update click handler to use new index
|
||||||
(selectBtn as HTMLElement).onclick = (e) => {
|
(selectBtn as HTMLElement).onclick = (e) => {
|
||||||
@@ -1128,17 +1331,47 @@ function updatePageDisplay() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Update action buttons
|
// Update action buttons
|
||||||
const actionsInner = card.querySelector('.flex.items-center.gap-1.bg-gray-900\\/90');
|
const actionsInner = card.querySelector(
|
||||||
|
'.flex.items-center.gap-1.bg-gray-900\\/90'
|
||||||
|
);
|
||||||
if (actionsInner) {
|
if (actionsInner) {
|
||||||
const buttons = actionsInner.querySelectorAll('button');
|
const buttons = actionsInner.querySelectorAll('button');
|
||||||
if (buttons[0]) (buttons[0] as HTMLElement).onclick = (e) => { e.stopPropagation(); rotatePage(index, -90); };
|
if (buttons[0])
|
||||||
if (buttons[1]) (buttons[1] as HTMLElement).onclick = (e) => { e.stopPropagation(); rotatePage(index, 90); };
|
(buttons[0] as HTMLElement).onclick = (e) => {
|
||||||
if (buttons[2]) (buttons[2] as HTMLElement).onclick = (e) => { e.stopPropagation(); snapshot(); duplicatePage(index); };
|
e.stopPropagation();
|
||||||
if (buttons[3]) (buttons[3] as HTMLElement).onclick = (e) => { e.stopPropagation(); snapshot(); insertPdfAfter(index); };
|
rotatePage(index, -90);
|
||||||
if (buttons[4]) (buttons[4] as HTMLElement).onclick = (e) => { e.stopPropagation(); snapshot(); toggleSplitMarker(index); renderSplitMarkers(); };
|
};
|
||||||
if (buttons[5]) (buttons[5] as HTMLElement).onclick = (e) => { e.stopPropagation(); snapshot(); deletePage(index); };
|
if (buttons[1])
|
||||||
|
(buttons[1] as HTMLElement).onclick = (e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
rotatePage(index, 90);
|
||||||
|
};
|
||||||
|
if (buttons[2])
|
||||||
|
(buttons[2] as HTMLElement).onclick = (e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
snapshot();
|
||||||
|
duplicatePage(index);
|
||||||
|
};
|
||||||
|
if (buttons[3])
|
||||||
|
(buttons[3] as HTMLElement).onclick = (e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
snapshot();
|
||||||
|
insertPdfAfter(index);
|
||||||
|
};
|
||||||
|
if (buttons[4])
|
||||||
|
(buttons[4] as HTMLElement).onclick = (e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
snapshot();
|
||||||
|
toggleSplitMarker(index);
|
||||||
|
renderSplitMarkers();
|
||||||
|
};
|
||||||
|
if (buttons[5])
|
||||||
|
(buttons[5] as HTMLElement).onclick = (e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
snapshot();
|
||||||
|
deletePage(index);
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
} else {
|
} else {
|
||||||
// Element doesn't exist, create it
|
// Element doesn't exist, create it
|
||||||
card = createPageElement(pageData.canvas, index);
|
card = createPageElement(pageData.canvas, index);
|
||||||
@@ -1179,7 +1412,9 @@ function updatePageNumbers() {
|
|||||||
// We need to find the buttons and update their onclick handlers
|
// We need to find the buttons and update their onclick handlers
|
||||||
// This is necessary because the original handlers captured the old index
|
// This is necessary because the original handlers captured the old index
|
||||||
|
|
||||||
const selectBtn = card.querySelector('button[class*="absolute top-2 right-2"]') as HTMLButtonElement;
|
const selectBtn = card.querySelector(
|
||||||
|
'button[class*="absolute top-2 right-2"]'
|
||||||
|
) as HTMLButtonElement;
|
||||||
if (selectBtn) {
|
if (selectBtn) {
|
||||||
selectBtn.onclick = (e) => {
|
selectBtn.onclick = (e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
@@ -1187,16 +1422,47 @@ function updatePageNumbers() {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const actionsInner = card.querySelector('.flex.items-center.gap-1.bg-gray-900\\/90');
|
const actionsInner = card.querySelector(
|
||||||
|
'.flex.items-center.gap-1.bg-gray-900\\/90'
|
||||||
|
);
|
||||||
if (actionsInner) {
|
if (actionsInner) {
|
||||||
const buttons = actionsInner.querySelectorAll('button');
|
const buttons = actionsInner.querySelectorAll('button');
|
||||||
// Order: Rotate Left, Rotate Right, Duplicate, Insert, Split, Delete
|
// Order: Rotate Left, Rotate Right, Duplicate, Insert, Split, Delete
|
||||||
if (buttons[0]) buttons[0].onclick = (e) => { e.stopPropagation(); rotatePage(index, -90); };
|
if (buttons[0])
|
||||||
if (buttons[1]) buttons[1].onclick = (e) => { e.stopPropagation(); rotatePage(index, 90); };
|
buttons[0].onclick = (e) => {
|
||||||
if (buttons[2]) buttons[2].onclick = (e) => { e.stopPropagation(); snapshot(); duplicatePage(index); };
|
e.stopPropagation();
|
||||||
if (buttons[3]) buttons[3].onclick = (e) => { e.stopPropagation(); snapshot(); insertPdfAfter(index); };
|
rotatePage(index, -90);
|
||||||
if (buttons[4]) buttons[4].onclick = (e) => { e.stopPropagation(); snapshot(); toggleSplitMarker(index); renderSplitMarkers(); };
|
};
|
||||||
if (buttons[5]) buttons[5].onclick = (e) => { e.stopPropagation(); snapshot(); deletePage(index); };
|
if (buttons[1])
|
||||||
|
buttons[1].onclick = (e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
rotatePage(index, 90);
|
||||||
|
};
|
||||||
|
if (buttons[2])
|
||||||
|
buttons[2].onclick = (e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
snapshot();
|
||||||
|
duplicatePage(index);
|
||||||
|
};
|
||||||
|
if (buttons[3])
|
||||||
|
buttons[3].onclick = (e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
snapshot();
|
||||||
|
insertPdfAfter(index);
|
||||||
|
};
|
||||||
|
if (buttons[4])
|
||||||
|
buttons[4].onclick = (e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
snapshot();
|
||||||
|
toggleSplitMarker(index);
|
||||||
|
renderSplitMarkers();
|
||||||
|
};
|
||||||
|
if (buttons[5])
|
||||||
|
buttons[5].onclick = (e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
snapshot();
|
||||||
|
deletePage(index);
|
||||||
|
};
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,11 @@ import { showAlert } from '../ui.js';
|
|||||||
import { tesseractLanguages } from '../config/tesseract-languages.js';
|
import { tesseractLanguages } from '../config/tesseract-languages.js';
|
||||||
import { createWorkflowEditor, updateNodeDisplay } from '../workflow/editor';
|
import { createWorkflowEditor, updateNodeDisplay } from '../workflow/editor';
|
||||||
import { executeWorkflow } from '../workflow/engine';
|
import { executeWorkflow } from '../workflow/engine';
|
||||||
import { nodeRegistry, getNodesByCategory } from '../workflow/nodes/registry';
|
import {
|
||||||
|
nodeRegistry,
|
||||||
|
getNodesByCategory,
|
||||||
|
createNodeByType,
|
||||||
|
} from '../workflow/nodes/registry';
|
||||||
import type { BaseWorkflowNode } from '../workflow/nodes/base-node';
|
import type { BaseWorkflowNode } from '../workflow/nodes/base-node';
|
||||||
import type { WorkflowEditor } from '../workflow/editor';
|
import type { WorkflowEditor } from '../workflow/editor';
|
||||||
import {
|
import {
|
||||||
@@ -43,6 +47,7 @@ import {
|
|||||||
|
|
||||||
let workflowEditor: WorkflowEditor | null = null;
|
let workflowEditor: WorkflowEditor | null = null;
|
||||||
let selectedNodeId: string | null = null;
|
let selectedNodeId: string | null = null;
|
||||||
|
let deleteNodeHandler: EventListener | null = null;
|
||||||
|
|
||||||
if (document.readyState === 'loading') {
|
if (document.readyState === 'loading') {
|
||||||
document.addEventListener('DOMContentLoaded', initializePage);
|
document.addEventListener('DOMContentLoaded', initializePage);
|
||||||
@@ -245,10 +250,14 @@ async function initializePage() {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
document.addEventListener('wf-delete-node', ((e: CustomEvent) => {
|
if (deleteNodeHandler) {
|
||||||
|
document.removeEventListener('wf-delete-node', deleteNodeHandler);
|
||||||
|
}
|
||||||
|
deleteNodeHandler = ((e: CustomEvent) => {
|
||||||
const nodeId = e.detail?.nodeId;
|
const nodeId = e.detail?.nodeId;
|
||||||
if (nodeId) deleteNodeById(nodeId);
|
if (nodeId) deleteNodeById(nodeId);
|
||||||
}) as EventListener);
|
}) as EventListener;
|
||||||
|
document.addEventListener('wf-delete-node', deleteNodeHandler);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function deleteNodeById(nodeId: string) {
|
async function deleteNodeById(nodeId: string) {
|
||||||
@@ -539,14 +548,12 @@ async function addNodeToCanvas(
|
|||||||
if (!workflowEditor) return;
|
if (!workflowEditor) return;
|
||||||
const { editor, area } = workflowEditor;
|
const { editor, area } = workflowEditor;
|
||||||
|
|
||||||
const entry = nodeRegistry[type];
|
|
||||||
if (!entry) {
|
|
||||||
console.error('Node type not found in registry:', type);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const node = entry.factory();
|
const node = createNodeByType(type);
|
||||||
|
if (!node) {
|
||||||
|
console.error('Node type not found in registry:', type);
|
||||||
|
return;
|
||||||
|
}
|
||||||
await editor.addNode(node);
|
await editor.addNode(node);
|
||||||
|
|
||||||
const pos = position || getCanvasCenter(area);
|
const pos = position || getCanvasCenter(area);
|
||||||
|
|||||||
@@ -1,65 +1,79 @@
|
|||||||
import { PDFDocument } from 'pdf-lib';
|
import { PDFDocument } from 'pdf-lib';
|
||||||
import * as pdfjsLib from 'pdfjs-dist';
|
import * as pdfjsLib from 'pdfjs-dist';
|
||||||
import { createIcons, icons } from 'lucide';
|
import { createIcons, icons } from 'lucide';
|
||||||
|
import { initPagePreview } from '../utils/page-preview.js';
|
||||||
|
|
||||||
pdfjsLib.GlobalWorkerOptions.workerSrc = new URL('pdfjs-dist/build/pdf.worker.min.mjs', import.meta.url).toString();
|
pdfjsLib.GlobalWorkerOptions.workerSrc = new URL(
|
||||||
|
'pdfjs-dist/build/pdf.worker.min.mjs',
|
||||||
|
import.meta.url
|
||||||
|
).toString();
|
||||||
|
|
||||||
// State
|
// State
|
||||||
const pageState: {
|
const pageState: {
|
||||||
pdfDoc: PDFDocument | null;
|
pdfDoc: PDFDocument | null;
|
||||||
file: File | null;
|
file: File | null;
|
||||||
detectedBlankPages: number[];
|
detectedBlankPages: number[];
|
||||||
pageThumbnails: Map<number, string>;
|
pageThumbnails: Map<number, string>;
|
||||||
} = {
|
} = {
|
||||||
pdfDoc: null,
|
pdfDoc: null,
|
||||||
file: null,
|
file: null,
|
||||||
detectedBlankPages: [],
|
detectedBlankPages: [],
|
||||||
pageThumbnails: new Map()
|
pageThumbnails: new Map(),
|
||||||
};
|
};
|
||||||
|
|
||||||
function showLoader(msg = 'Processing...') {
|
function showLoader(msg = 'Processing...') {
|
||||||
document.getElementById('loader-modal')?.classList.remove('hidden');
|
document.getElementById('loader-modal')?.classList.remove('hidden');
|
||||||
const txt = document.getElementById('loader-text');
|
const txt = document.getElementById('loader-text');
|
||||||
if (txt) txt.textContent = msg;
|
if (txt) txt.textContent = msg;
|
||||||
}
|
}
|
||||||
|
|
||||||
function hideLoader() { document.getElementById('loader-modal')?.classList.add('hidden'); }
|
function hideLoader() {
|
||||||
|
document.getElementById('loader-modal')?.classList.add('hidden');
|
||||||
|
}
|
||||||
|
|
||||||
function showAlert(title: string, msg: string, type = 'error', cb?: () => void) {
|
function showAlert(
|
||||||
const modal = document.getElementById('alert-modal');
|
title: string,
|
||||||
const t = document.getElementById('alert-title');
|
msg: string,
|
||||||
const m = document.getElementById('alert-message');
|
type = 'error',
|
||||||
if (t) t.textContent = title;
|
cb?: () => void
|
||||||
if (m) m.textContent = msg;
|
) {
|
||||||
modal?.classList.remove('hidden');
|
const modal = document.getElementById('alert-modal');
|
||||||
const okBtn = document.getElementById('alert-ok');
|
const t = document.getElementById('alert-title');
|
||||||
if (okBtn) {
|
const m = document.getElementById('alert-message');
|
||||||
const newBtn = okBtn.cloneNode(true) as HTMLElement;
|
if (t) t.textContent = title;
|
||||||
okBtn.replaceWith(newBtn);
|
if (m) m.textContent = msg;
|
||||||
newBtn.addEventListener('click', () => {
|
modal?.classList.remove('hidden');
|
||||||
modal?.classList.add('hidden');
|
const okBtn = document.getElementById('alert-ok');
|
||||||
if (cb) cb();
|
if (okBtn) {
|
||||||
});
|
const newBtn = okBtn.cloneNode(true) as HTMLElement;
|
||||||
}
|
okBtn.replaceWith(newBtn);
|
||||||
|
newBtn.addEventListener('click', () => {
|
||||||
|
modal?.classList.add('hidden');
|
||||||
|
if (cb) cb();
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function downloadFile(blob: Blob, filename: string) {
|
function downloadFile(blob: Blob, filename: string) {
|
||||||
const url = URL.createObjectURL(blob);
|
const url = URL.createObjectURL(blob);
|
||||||
const a = document.createElement('a');
|
const a = document.createElement('a');
|
||||||
a.href = url; a.download = filename; a.click();
|
a.href = url;
|
||||||
URL.revokeObjectURL(url);
|
a.download = filename;
|
||||||
|
a.click();
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
}
|
}
|
||||||
|
|
||||||
function updateFileDisplay() {
|
function updateFileDisplay() {
|
||||||
const area = document.getElementById('file-display-area');
|
const area = document.getElementById('file-display-area');
|
||||||
if (!area || !pageState.file || !pageState.pdfDoc) return;
|
if (!area || !pageState.file || !pageState.pdfDoc) return;
|
||||||
|
|
||||||
const fileSize = pageState.file.size < 1024 * 1024
|
const fileSize =
|
||||||
? `${(pageState.file.size / 1024).toFixed(1)} KB`
|
pageState.file.size < 1024 * 1024
|
||||||
: `${(pageState.file.size / 1024 / 1024).toFixed(2)} MB`;
|
? `${(pageState.file.size / 1024).toFixed(1)} KB`
|
||||||
const pageCount = pageState.pdfDoc.getPageCount();
|
: `${(pageState.file.size / 1024 / 1024).toFixed(2)} MB`;
|
||||||
|
const pageCount = pageState.pdfDoc.getPageCount();
|
||||||
|
|
||||||
area.innerHTML = `
|
area.innerHTML = `
|
||||||
<div class="bg-gray-700 p-3 rounded-lg border border-gray-600 hover:border-indigo-500 transition-colors">
|
<div class="bg-gray-700 p-3 rounded-lg border border-gray-600 hover:border-indigo-500 transition-colors">
|
||||||
<div class="flex items-center justify-between">
|
<div class="flex items-center justify-between">
|
||||||
<div class="flex-1 min-w-0">
|
<div class="flex-1 min-w-0">
|
||||||
@@ -72,257 +86,285 @@ function updateFileDisplay() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
createIcons({ icons });
|
createIcons({ icons });
|
||||||
document.getElementById('remove-file')?.addEventListener('click', resetState);
|
document.getElementById('remove-file')?.addEventListener('click', resetState);
|
||||||
}
|
}
|
||||||
|
|
||||||
function resetState() {
|
function resetState() {
|
||||||
pageState.pdfDoc = null;
|
pageState.pdfDoc = null;
|
||||||
pageState.file = null;
|
pageState.file = null;
|
||||||
pageState.detectedBlankPages = [];
|
pageState.detectedBlankPages = [];
|
||||||
pageState.pageThumbnails.forEach(url => URL.revokeObjectURL(url));
|
pageState.pageThumbnails.forEach((url) => URL.revokeObjectURL(url));
|
||||||
pageState.pageThumbnails.clear();
|
pageState.pageThumbnails.clear();
|
||||||
|
|
||||||
const area = document.getElementById('file-display-area');
|
const area = document.getElementById('file-display-area');
|
||||||
if (area) area.innerHTML = '';
|
if (area) area.innerHTML = '';
|
||||||
document.getElementById('options-panel')?.classList.add('hidden');
|
document.getElementById('options-panel')?.classList.add('hidden');
|
||||||
document.getElementById('preview-panel')?.classList.add('hidden');
|
document.getElementById('preview-panel')?.classList.add('hidden');
|
||||||
const inp = document.getElementById('file-input') as HTMLInputElement;
|
const inp = document.getElementById('file-input') as HTMLInputElement;
|
||||||
if (inp) inp.value = '';
|
if (inp) inp.value = '';
|
||||||
|
const slider = document.getElementById(
|
||||||
|
'sensitivity-slider'
|
||||||
|
) as HTMLInputElement;
|
||||||
|
if (slider) slider.value = '80';
|
||||||
|
const sliderLabel = document.getElementById('sensitivity-value');
|
||||||
|
if (sliderLabel) sliderLabel.textContent = '80';
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleFileUpload(file: File) {
|
async function handleFileUpload(file: File) {
|
||||||
if (!file || file.type !== 'application/pdf') {
|
if (!file || file.type !== 'application/pdf') {
|
||||||
showAlert('Error', 'Please upload a valid PDF file.');
|
showAlert('Error', 'Please upload a valid PDF file.');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
showLoader('Loading PDF...');
|
showLoader('Loading PDF...');
|
||||||
try {
|
try {
|
||||||
const buf = await file.arrayBuffer();
|
const buf = await file.arrayBuffer();
|
||||||
pageState.pdfDoc = await PDFDocument.load(buf);
|
pageState.pdfDoc = await PDFDocument.load(buf);
|
||||||
pageState.file = file;
|
pageState.file = file;
|
||||||
pageState.detectedBlankPages = [];
|
pageState.detectedBlankPages = [];
|
||||||
updateFileDisplay();
|
updateFileDisplay();
|
||||||
document.getElementById('options-panel')?.classList.remove('hidden');
|
document.getElementById('options-panel')?.classList.remove('hidden');
|
||||||
document.getElementById('preview-panel')?.classList.add('hidden');
|
document.getElementById('preview-panel')?.classList.add('hidden');
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error(e);
|
console.error(e);
|
||||||
showAlert('Error', 'Failed to load PDF file.');
|
showAlert('Error', 'Failed to load PDF file.');
|
||||||
} finally {
|
} finally {
|
||||||
hideLoader();
|
hideLoader();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function isPageBlank(page: any, threshold = 250): Promise<boolean> {
|
async function isPageBlank(
|
||||||
const viewport = page.getViewport({ scale: 0.5 }); // Lower scale for faster processing
|
page: any,
|
||||||
const canvas = document.createElement('canvas');
|
maxNonWhitePercent = 0.5
|
||||||
const ctx = canvas.getContext('2d');
|
): Promise<boolean> {
|
||||||
if (!ctx) return false;
|
const viewport = page.getViewport({ scale: 0.5 });
|
||||||
|
const canvas = document.createElement('canvas');
|
||||||
|
const ctx = canvas.getContext('2d');
|
||||||
|
if (!ctx) return false;
|
||||||
|
|
||||||
canvas.width = viewport.width;
|
canvas.width = viewport.width;
|
||||||
canvas.height = viewport.height;
|
canvas.height = viewport.height;
|
||||||
|
|
||||||
await page.render({ canvasContext: ctx, viewport }).promise;
|
await page.render({ canvasContext: ctx, viewport }).promise;
|
||||||
|
|
||||||
const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
|
const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
|
||||||
const data = imageData.data;
|
const data = imageData.data;
|
||||||
|
const totalPixels = data.length / 4;
|
||||||
|
|
||||||
let totalBrightness = 0;
|
let nonWhitePixels = 0;
|
||||||
for (let i = 0; i < data.length; i += 4) {
|
for (let i = 0; i < data.length; i += 4) {
|
||||||
const r = data[i], g = data[i + 1], b = data[i + 2];
|
const brightness = (data[i] + data[i + 1] + data[i + 2]) / 3;
|
||||||
totalBrightness += (r + g + b) / 3;
|
if (brightness < 240) nonWhitePixels++;
|
||||||
}
|
}
|
||||||
|
|
||||||
const avgBrightness = totalBrightness / (data.length / 4);
|
const nonWhitePercent = (nonWhitePixels / totalPixels) * 100;
|
||||||
return avgBrightness > threshold;
|
return nonWhitePercent <= maxNonWhitePercent;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function generateThumbnail(page: any): Promise<string> {
|
async function generateThumbnail(page: any): Promise<string> {
|
||||||
const viewport = page.getViewport({ scale: 0.3 });
|
const viewport = page.getViewport({ scale: 1 });
|
||||||
const canvas = document.createElement('canvas');
|
const canvas = document.createElement('canvas');
|
||||||
const ctx = canvas.getContext('2d');
|
const ctx = canvas.getContext('2d');
|
||||||
if (!ctx) return '';
|
if (!ctx) return '';
|
||||||
|
|
||||||
canvas.width = viewport.width;
|
canvas.width = viewport.width;
|
||||||
canvas.height = viewport.height;
|
canvas.height = viewport.height;
|
||||||
|
|
||||||
await page.render({ canvasContext: ctx, viewport }).promise;
|
await page.render({ canvasContext: ctx, viewport }).promise;
|
||||||
return canvas.toDataURL('image/jpeg', 0.7);
|
return canvas.toDataURL('image/jpeg', 0.7);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function detectBlankPages() {
|
async function detectBlankPages() {
|
||||||
if (!pageState.pdfDoc || !pageState.file) return showAlert('Error', 'Please upload a PDF first.');
|
if (!pageState.pdfDoc || !pageState.file)
|
||||||
|
return showAlert('Error', 'Please upload a PDF first.');
|
||||||
|
|
||||||
const sensitivitySlider = document.getElementById('sensitivity-slider') as HTMLInputElement;
|
const sensitivitySlider = document.getElementById(
|
||||||
const sensitivityPercent = parseInt(sensitivitySlider?.value || '80');
|
'sensitivity-slider'
|
||||||
const threshold = Math.round(255 - (sensitivityPercent * 2.55));
|
) as HTMLInputElement;
|
||||||
|
const sensitivityPercent = parseInt(sensitivitySlider?.value || '80');
|
||||||
|
const maxNonWhitePercent = 5 - (sensitivityPercent / 100) * 4.9;
|
||||||
|
|
||||||
showLoader('Detecting blank pages...');
|
showLoader('Detecting blank pages...');
|
||||||
try {
|
try {
|
||||||
const pdfData = await pageState.file.arrayBuffer();
|
const pdfData = await pageState.file.arrayBuffer();
|
||||||
const pdfDoc = await pdfjsLib.getDocument({ data: pdfData }).promise;
|
const pdfDoc = await pdfjsLib.getDocument({ data: pdfData }).promise;
|
||||||
const totalPages = pdfDoc.numPages;
|
const totalPages = pdfDoc.numPages;
|
||||||
|
|
||||||
pageState.detectedBlankPages = [];
|
pageState.detectedBlankPages = [];
|
||||||
pageState.pageThumbnails.forEach(url => URL.revokeObjectURL(url));
|
pageState.pageThumbnails.forEach((url) => URL.revokeObjectURL(url));
|
||||||
pageState.pageThumbnails.clear();
|
pageState.pageThumbnails.clear();
|
||||||
|
|
||||||
for (let i = 1; i <= totalPages; i++) {
|
for (let i = 1; i <= totalPages; i++) {
|
||||||
const page = await pdfDoc.getPage(i);
|
const page = await pdfDoc.getPage(i);
|
||||||
if (await isPageBlank(page, threshold)) {
|
if (await isPageBlank(page, maxNonWhitePercent)) {
|
||||||
pageState.detectedBlankPages.push(i - 1); // 0-indexed
|
pageState.detectedBlankPages.push(i - 1); // 0-indexed
|
||||||
const thumbnail = await generateThumbnail(page);
|
const thumbnail = await generateThumbnail(page);
|
||||||
pageState.pageThumbnails.set(i - 1, thumbnail);
|
pageState.pageThumbnails.set(i - 1, thumbnail);
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
if (pageState.detectedBlankPages.length === 0) {
|
|
||||||
showAlert('Info', 'No blank pages detected in this PDF.');
|
|
||||||
hideLoader();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Show preview panel
|
|
||||||
updatePreviewPanel();
|
|
||||||
document.getElementById('preview-panel')?.classList.remove('hidden');
|
|
||||||
hideLoader();
|
|
||||||
} catch (e) {
|
|
||||||
console.error(e);
|
|
||||||
showAlert('Error', 'Could not detect blank pages.');
|
|
||||||
hideLoader();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (pageState.detectedBlankPages.length === 0) {
|
||||||
|
showAlert('Info', 'No blank pages detected in this PDF.');
|
||||||
|
hideLoader();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show preview panel
|
||||||
|
updatePreviewPanel();
|
||||||
|
document.getElementById('preview-panel')?.classList.remove('hidden');
|
||||||
|
|
||||||
|
const previewContainer = document.getElementById('blank-pages-preview');
|
||||||
|
if (previewContainer) initPagePreview(previewContainer, pdfDoc);
|
||||||
|
|
||||||
|
hideLoader();
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e);
|
||||||
|
showAlert('Error', 'Could not detect blank pages.');
|
||||||
|
hideLoader();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function updatePreviewPanel() {
|
function updatePreviewPanel() {
|
||||||
const previewInfo = document.getElementById('preview-info');
|
const previewInfo = document.getElementById('preview-info');
|
||||||
const previewContainer = document.getElementById('blank-pages-preview');
|
const previewContainer = document.getElementById('blank-pages-preview');
|
||||||
|
|
||||||
if (!previewInfo || !previewContainer) return;
|
if (!previewInfo || !previewContainer) return;
|
||||||
|
|
||||||
previewInfo.textContent = `Found ${pageState.detectedBlankPages.length} blank page(s). Click on a page to deselect it.`;
|
previewInfo.textContent = `Found ${pageState.detectedBlankPages.length} blank page(s). Click on a page to deselect it.`;
|
||||||
previewContainer.innerHTML = '';
|
previewContainer.innerHTML = '';
|
||||||
|
|
||||||
pageState.detectedBlankPages.forEach((pageIndex) => {
|
pageState.detectedBlankPages.forEach((pageIndex) => {
|
||||||
const thumbnail = pageState.pageThumbnails.get(pageIndex) || '';
|
const thumbnail = pageState.pageThumbnails.get(pageIndex) || '';
|
||||||
const div = document.createElement('div');
|
const div = document.createElement('div');
|
||||||
div.className = 'relative cursor-pointer group';
|
div.className =
|
||||||
div.dataset.pageIndex = String(pageIndex);
|
'relative cursor-pointer flex flex-col items-center gap-1 p-2 border-2 border-red-500 rounded-lg bg-gray-700 transition-colors group';
|
||||||
div.dataset.selected = 'true';
|
div.dataset.pageIndex = String(pageIndex);
|
||||||
|
div.dataset.selected = 'true';
|
||||||
|
|
||||||
div.innerHTML = `
|
div.innerHTML = `
|
||||||
<div class="relative border-2 border-red-500 rounded-lg overflow-hidden transition-all">
|
<div class="relative">
|
||||||
<img src="${thumbnail}" alt="Page ${pageIndex + 1}" class="w-full h-auto">
|
<img src="${thumbnail}" alt="Page ${pageIndex + 1}" class="rounded-md shadow-md max-w-full h-auto">
|
||||||
<div class="absolute bottom-0 left-0 right-0 bg-black/70 text-white text-xs text-center py-1">
|
<div class="absolute top-1 left-1 bg-indigo-600 text-white text-xs px-2 py-1 rounded-md font-semibold shadow-lg z-10 pointer-events-none">
|
||||||
Page ${pageIndex + 1}
|
${pageIndex + 1}
|
||||||
</div>
|
</div>
|
||||||
<div class="absolute top-1 right-1 bg-red-500 rounded-full w-5 h-5 flex items-center justify-center check-mark">
|
<div class="absolute top-1 right-1 bg-red-500 rounded-full w-5 h-5 flex items-center justify-center check-mark z-10">
|
||||||
<i data-lucide="check" class="w-3 h-3 text-white"></i>
|
<i data-lucide="check" class="w-3 h-3 text-white"></i>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
|
|
||||||
div.addEventListener('click', () => togglePageSelection(div, pageIndex));
|
div.addEventListener('click', () => togglePageSelection(div, pageIndex));
|
||||||
previewContainer.appendChild(div);
|
previewContainer.appendChild(div);
|
||||||
});
|
});
|
||||||
|
|
||||||
createIcons({ icons });
|
createIcons({ icons });
|
||||||
}
|
}
|
||||||
|
|
||||||
function togglePageSelection(div: HTMLElement, pageIndex: number) {
|
function togglePageSelection(div: HTMLElement, pageIndex: number) {
|
||||||
const isSelected = div.dataset.selected === 'true';
|
const isSelected = div.dataset.selected === 'true';
|
||||||
const border = div.querySelector('.border-2') as HTMLElement;
|
const checkMark = div.querySelector('.check-mark') as HTMLElement;
|
||||||
const checkMark = div.querySelector('.check-mark') as HTMLElement;
|
|
||||||
|
|
||||||
if (isSelected) {
|
if (isSelected) {
|
||||||
div.dataset.selected = 'false';
|
div.dataset.selected = 'false';
|
||||||
border?.classList.remove('border-red-500');
|
div.classList.remove('border-red-500');
|
||||||
border?.classList.add('border-gray-500', 'opacity-50');
|
div.classList.add('border-gray-600', 'opacity-50');
|
||||||
checkMark?.classList.add('hidden');
|
checkMark?.classList.add('hidden');
|
||||||
} else {
|
} else {
|
||||||
div.dataset.selected = 'true';
|
div.dataset.selected = 'true';
|
||||||
border?.classList.add('border-red-500');
|
div.classList.add('border-red-500');
|
||||||
border?.classList.remove('border-gray-500', 'opacity-50');
|
div.classList.remove('border-gray-600', 'opacity-50');
|
||||||
checkMark?.classList.remove('hidden');
|
checkMark?.classList.remove('hidden');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function processRemoveBlankPages() {
|
async function processRemoveBlankPages() {
|
||||||
if (!pageState.pdfDoc || !pageState.file) return showAlert('Error', 'Please upload a PDF first.');
|
if (!pageState.pdfDoc || !pageState.file)
|
||||||
|
return showAlert('Error', 'Please upload a PDF first.');
|
||||||
|
|
||||||
// Get selected pages to remove
|
// Get selected pages to remove
|
||||||
const previewContainer = document.getElementById('blank-pages-preview');
|
const previewContainer = document.getElementById('blank-pages-preview');
|
||||||
const selectedPages: number[] = [];
|
const selectedPages: number[] = [];
|
||||||
previewContainer?.querySelectorAll('[data-selected="true"]').forEach(el => {
|
previewContainer?.querySelectorAll('[data-selected="true"]').forEach((el) => {
|
||||||
const pageIndex = parseInt((el as HTMLElement).dataset.pageIndex || '-1');
|
const pageIndex = parseInt((el as HTMLElement).dataset.pageIndex || '-1');
|
||||||
if (pageIndex >= 0) selectedPages.push(pageIndex);
|
if (pageIndex >= 0) selectedPages.push(pageIndex);
|
||||||
});
|
});
|
||||||
|
|
||||||
if (selectedPages.length === 0) {
|
if (selectedPages.length === 0) {
|
||||||
showAlert('Info', 'No pages selected for removal.');
|
showAlert('Info', 'No pages selected for removal.');
|
||||||
return;
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
showLoader(`Removing ${selectedPages.length} blank page(s)...`);
|
||||||
|
try {
|
||||||
|
const newPdf = await PDFDocument.create();
|
||||||
|
const pages = pageState.pdfDoc.getPages();
|
||||||
|
|
||||||
|
for (let i = 0; i < pages.length; i++) {
|
||||||
|
if (!selectedPages.includes(i)) {
|
||||||
|
const [copiedPage] = await newPdf.copyPages(pageState.pdfDoc, [i]);
|
||||||
|
newPdf.addPage(copiedPage);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
showLoader(`Removing ${selectedPages.length} blank page(s)...`);
|
const newPdfBytes = await newPdf.save();
|
||||||
try {
|
downloadFile(
|
||||||
const newPdf = await PDFDocument.create();
|
new Blob([new Uint8Array(newPdfBytes)], { type: 'application/pdf' }),
|
||||||
const pages = pageState.pdfDoc.getPages();
|
'blank-pages-removed.pdf'
|
||||||
|
);
|
||||||
for (let i = 0; i < pages.length; i++) {
|
showAlert(
|
||||||
if (!selectedPages.includes(i)) {
|
'Success',
|
||||||
const [copiedPage] = await newPdf.copyPages(pageState.pdfDoc, [i]);
|
`Removed ${selectedPages.length} blank page(s) successfully!`,
|
||||||
newPdf.addPage(copiedPage);
|
'success',
|
||||||
}
|
resetState
|
||||||
}
|
);
|
||||||
|
} catch (e) {
|
||||||
const newPdfBytes = await newPdf.save();
|
console.error(e);
|
||||||
downloadFile(new Blob([new Uint8Array(newPdfBytes)], { type: 'application/pdf' }), 'blank-pages-removed.pdf');
|
showAlert('Error', 'Could not remove blank pages.');
|
||||||
showAlert('Success', `Removed ${selectedPages.length} blank page(s) successfully!`, 'success', resetState);
|
} finally {
|
||||||
} catch (e) {
|
hideLoader();
|
||||||
console.error(e);
|
}
|
||||||
showAlert('Error', 'Could not remove blank pages.');
|
|
||||||
} finally {
|
|
||||||
hideLoader();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
document.addEventListener('DOMContentLoaded', () => {
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
const fileInput = document.getElementById('file-input') as HTMLInputElement;
|
const fileInput = document.getElementById('file-input') as HTMLInputElement;
|
||||||
const dropZone = document.getElementById('drop-zone');
|
const dropZone = document.getElementById('drop-zone');
|
||||||
const detectBtn = document.getElementById('detect-btn');
|
const detectBtn = document.getElementById('detect-btn');
|
||||||
const processBtn = document.getElementById('process-btn');
|
const processBtn = document.getElementById('process-btn');
|
||||||
const sensitivitySlider = document.getElementById('sensitivity-slider') as HTMLInputElement;
|
const sensitivitySlider = document.getElementById(
|
||||||
const sensitivityValue = document.getElementById('sensitivity-value');
|
'sensitivity-slider'
|
||||||
|
) as HTMLInputElement;
|
||||||
|
const sensitivityValue = document.getElementById('sensitivity-value');
|
||||||
|
|
||||||
sensitivitySlider?.addEventListener('input', (e) => {
|
sensitivitySlider?.addEventListener('input', (e) => {
|
||||||
const value = (e.target as HTMLInputElement).value;
|
const value = (e.target as HTMLInputElement).value;
|
||||||
if (sensitivityValue) sensitivityValue.textContent = value;
|
if (sensitivityValue) sensitivityValue.textContent = value;
|
||||||
});
|
});
|
||||||
|
|
||||||
fileInput?.addEventListener('change', (e) => {
|
fileInput?.addEventListener('change', (e) => {
|
||||||
const f = (e.target as HTMLInputElement).files?.[0];
|
const f = (e.target as HTMLInputElement).files?.[0];
|
||||||
if (f) handleFileUpload(f);
|
if (f) handleFileUpload(f);
|
||||||
});
|
});
|
||||||
|
|
||||||
dropZone?.addEventListener('dragover', (e) => {
|
dropZone?.addEventListener('dragover', (e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
dropZone.classList.add('border-indigo-500');
|
dropZone.classList.add('border-indigo-500');
|
||||||
});
|
});
|
||||||
|
|
||||||
dropZone?.addEventListener('dragleave', () => {
|
dropZone?.addEventListener('dragleave', () => {
|
||||||
dropZone.classList.remove('border-indigo-500');
|
dropZone.classList.remove('border-indigo-500');
|
||||||
});
|
});
|
||||||
|
|
||||||
dropZone?.addEventListener('drop', (e) => {
|
dropZone?.addEventListener('drop', (e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
dropZone.classList.remove('border-indigo-500');
|
dropZone.classList.remove('border-indigo-500');
|
||||||
const f = e.dataTransfer?.files[0];
|
const f = e.dataTransfer?.files[0];
|
||||||
if (f) handleFileUpload(f);
|
if (f) handleFileUpload(f);
|
||||||
});
|
});
|
||||||
|
|
||||||
detectBtn?.addEventListener('click', detectBlankPages);
|
detectBtn?.addEventListener('click', detectBlankPages);
|
||||||
processBtn?.addEventListener('click', processRemoveBlankPages);
|
processBtn?.addEventListener('click', processRemoveBlankPages);
|
||||||
|
|
||||||
document.getElementById('back-to-tools')?.addEventListener('click', () => {
|
document.getElementById('back-to-tools')?.addEventListener('click', () => {
|
||||||
window.location.href = '../../index.html';
|
window.location.href = '../../index.html';
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import {
|
|||||||
renderPagesProgressively,
|
renderPagesProgressively,
|
||||||
cleanupLazyRendering,
|
cleanupLazyRendering,
|
||||||
} from '../utils/render-utils.js';
|
} from '../utils/render-utils.js';
|
||||||
|
import { initPagePreview } from '../utils/page-preview.js';
|
||||||
import { isCpdfAvailable } from '../utils/cpdf-helper.js';
|
import { isCpdfAvailable } from '../utils/cpdf-helper.js';
|
||||||
import { showWasmRequiredDialog } from '../utils/wasm-provider.js';
|
import { showWasmRequiredDialog } from '../utils/wasm-provider.js';
|
||||||
import JSZip from 'jszip';
|
import JSZip from 'jszip';
|
||||||
@@ -153,18 +154,24 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
const createWrapper = (canvas: HTMLCanvasElement, pageNumber: number) => {
|
const createWrapper = (canvas: HTMLCanvasElement, pageNumber: number) => {
|
||||||
const wrapper = document.createElement('div');
|
const wrapper = document.createElement('div');
|
||||||
wrapper.className =
|
wrapper.className =
|
||||||
'page-thumbnail-wrapper p-1 border-2 border-transparent rounded-lg cursor-pointer hover:border-indigo-500 relative';
|
'page-thumbnail-wrapper p-2 border-2 border-gray-600 rounded-lg cursor-pointer hover:border-indigo-500 bg-gray-700 transition-colors relative group flex flex-col items-center gap-1';
|
||||||
wrapper.dataset.pageIndex = (pageNumber - 1).toString();
|
wrapper.dataset.pageIndex = (pageNumber - 1).toString();
|
||||||
|
wrapper.dataset.pageNumber = pageNumber.toString();
|
||||||
|
|
||||||
|
const imgContainer = document.createElement('div');
|
||||||
|
imgContainer.className = 'relative';
|
||||||
|
|
||||||
const img = document.createElement('img');
|
const img = document.createElement('img');
|
||||||
img.src = canvas.toDataURL();
|
img.src = canvas.toDataURL();
|
||||||
img.className = 'rounded-md w-full h-auto';
|
img.className = 'rounded-md shadow-md max-w-full h-auto';
|
||||||
|
|
||||||
const p = document.createElement('p');
|
const pageNumDiv = document.createElement('div');
|
||||||
p.className = 'text-center text-xs mt-1 text-gray-300';
|
pageNumDiv.className =
|
||||||
p.textContent = `Page ${pageNumber}`;
|
'absolute top-1 left-1 bg-indigo-600 text-white text-xs px-2 py-1 rounded-md font-semibold shadow-lg z-10 pointer-events-none';
|
||||||
|
pageNumDiv.textContent = pageNumber.toString();
|
||||||
|
|
||||||
wrapper.append(img, p);
|
imgContainer.append(img, pageNumDiv);
|
||||||
|
wrapper.appendChild(imgContainer);
|
||||||
|
|
||||||
const handleSelection = (e: any) => {
|
const handleSelection = (e: any) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
@@ -174,10 +181,10 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
|
|
||||||
if (isSelected) {
|
if (isSelected) {
|
||||||
wrapper.classList.remove('selected', 'border-indigo-500');
|
wrapper.classList.remove('selected', 'border-indigo-500');
|
||||||
wrapper.classList.add('border-transparent');
|
wrapper.classList.add('border-gray-600');
|
||||||
} else {
|
} else {
|
||||||
wrapper.classList.add('selected', 'border-indigo-500');
|
wrapper.classList.add('selected', 'border-indigo-500');
|
||||||
wrapper.classList.remove('border-transparent');
|
wrapper.classList.remove('border-gray-600');
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -203,6 +210,8 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
createIcons({ icons });
|
createIcons({ icons });
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
initPagePreview(container, pdf);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error rendering visual selector:', error);
|
console.error('Error rendering visual selector:', error);
|
||||||
showAlert('Error', 'Failed to render page previews.');
|
showAlert('Error', 'Failed to render page previews.');
|
||||||
|
|||||||
@@ -138,6 +138,7 @@ const init = async () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const toolTranslationKeys: Record<string, string> = {
|
const toolTranslationKeys: Record<string, string> = {
|
||||||
|
'PDF Workflow Builder': 'tools:pdfWorkflow',
|
||||||
'PDF Multi Tool': 'tools:pdfMultiTool',
|
'PDF Multi Tool': 'tools:pdfMultiTool',
|
||||||
'Merge PDF': 'tools:mergePdf',
|
'Merge PDF': 'tools:mergePdf',
|
||||||
'Split PDF': 'tools:splitPdf',
|
'Split PDF': 'tools:splitPdf',
|
||||||
@@ -158,6 +159,7 @@ const init = async () => {
|
|||||||
'Background Color': 'tools:backgroundColor',
|
'Background Color': 'tools:backgroundColor',
|
||||||
'Change Text Color': 'tools:changeTextColor',
|
'Change Text Color': 'tools:changeTextColor',
|
||||||
'Add Stamps': 'tools:addStamps',
|
'Add Stamps': 'tools:addStamps',
|
||||||
|
'Bates Numbering': 'tools:batesNumbering',
|
||||||
'Remove Annotations': 'tools:removeAnnotations',
|
'Remove Annotations': 'tools:removeAnnotations',
|
||||||
'PDF Form Filler': 'tools:pdfFormFiller',
|
'PDF Form Filler': 'tools:pdfFormFiller',
|
||||||
'Create PDF Form': 'tools:createPdfForm',
|
'Create PDF Form': 'tools:createPdfForm',
|
||||||
|
|||||||
@@ -1,6 +1,26 @@
|
|||||||
import { PDFDocument as PDFLibDocument } from 'pdf-lib';
|
import { PDFDocument as PDFLibDocument } from 'pdf-lib';
|
||||||
|
|
||||||
export interface AddWatermarkState {
|
export interface AddWatermarkState {
|
||||||
file: File | null;
|
file: File | null;
|
||||||
pdfDoc: PDFLibDocument | null;
|
pdfDoc: PDFLibDocument | null;
|
||||||
}
|
pdfBytes: Uint8Array | null;
|
||||||
|
previewCanvas: HTMLCanvasElement | null;
|
||||||
|
watermarkX: number; // 0–1, percentage from left
|
||||||
|
watermarkY: number; // 0–1, percentage from top (flipped to bottom for PDF)
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PageWatermarkConfig {
|
||||||
|
type: 'text' | 'image';
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
text: string;
|
||||||
|
fontSize: number;
|
||||||
|
color: string;
|
||||||
|
opacityText: number;
|
||||||
|
angleText: number;
|
||||||
|
imageDataUrl: string | null;
|
||||||
|
imageFile: File | null;
|
||||||
|
imageScale: number;
|
||||||
|
opacityImage: number;
|
||||||
|
angleImage: number;
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,3 +1,3 @@
|
|||||||
export interface DecryptPdfState {
|
export interface DecryptPdfState {
|
||||||
file: File | null;
|
files: File[];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,40 +1,52 @@
|
|||||||
export interface FormField {
|
export interface FormField {
|
||||||
id: string
|
id: string;
|
||||||
type: 'text' | 'checkbox' | 'radio' | 'dropdown' | 'optionlist' | 'button' | 'signature' | 'date' | 'image'
|
type:
|
||||||
x: number
|
| 'text'
|
||||||
y: number
|
| 'checkbox'
|
||||||
width: number
|
| 'radio'
|
||||||
height: number
|
| 'dropdown'
|
||||||
name: string
|
| 'optionlist'
|
||||||
defaultValue: string
|
| 'button'
|
||||||
fontSize: number
|
| 'signature'
|
||||||
alignment: 'left' | 'center' | 'right'
|
| 'date'
|
||||||
textColor: string
|
| 'image'
|
||||||
required: boolean
|
| 'barcode';
|
||||||
readOnly: boolean
|
x: number;
|
||||||
tooltip: string
|
y: number;
|
||||||
combCells: number
|
width: number;
|
||||||
maxLength: number
|
height: number;
|
||||||
options?: string[]
|
name: string;
|
||||||
checked?: boolean
|
defaultValue: string;
|
||||||
exportValue?: string
|
fontSize: number;
|
||||||
groupName?: string
|
alignment: 'left' | 'center' | 'right';
|
||||||
label?: string
|
textColor: string;
|
||||||
pageIndex: number
|
required: boolean;
|
||||||
action?: 'none' | 'reset' | 'print' | 'url' | 'js' | 'showHide'
|
readOnly: boolean;
|
||||||
actionUrl?: string
|
tooltip: string;
|
||||||
jsScript?: string
|
combCells: number;
|
||||||
targetFieldName?: string
|
maxLength: number;
|
||||||
visibilityAction?: 'show' | 'hide' | 'toggle'
|
options?: string[];
|
||||||
dateFormat?: string
|
checked?: boolean;
|
||||||
multiline?: boolean
|
exportValue?: string;
|
||||||
borderColor?: string
|
groupName?: string;
|
||||||
hideBorder?: boolean
|
label?: string;
|
||||||
}
|
pageIndex: number;
|
||||||
|
action?: 'none' | 'reset' | 'print' | 'url' | 'js' | 'showHide';
|
||||||
export interface PageData {
|
actionUrl?: string;
|
||||||
index: number
|
jsScript?: string;
|
||||||
width: number
|
targetFieldName?: string;
|
||||||
height: number
|
visibilityAction?: 'show' | 'hide' | 'toggle';
|
||||||
pdfPageData?: string
|
dateFormat?: string;
|
||||||
}
|
multiline?: boolean;
|
||||||
|
borderColor?: string;
|
||||||
|
hideBorder?: boolean;
|
||||||
|
barcodeFormat?: string;
|
||||||
|
barcodeValue?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PageData {
|
||||||
|
index: number;
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
pdfPageData?: string;
|
||||||
|
}
|
||||||
|
|||||||
@@ -50,3 +50,4 @@ export * from './bookmark-pdf-type.ts';
|
|||||||
export * from './scanner-effect-type.ts';
|
export * from './scanner-effect-type.ts';
|
||||||
export * from './adjust-colors-type.ts';
|
export * from './adjust-colors-type.ts';
|
||||||
export * from './bates-numbering-type.ts';
|
export * from './bates-numbering-type.ts';
|
||||||
|
export * from './page-preview-type.ts';
|
||||||
|
|||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import { PDFDocumentProxy } from 'pdfjs-dist';
|
||||||
|
|
||||||
|
export interface PreviewState {
|
||||||
|
modal: HTMLElement | null;
|
||||||
|
pdfjsDoc: PDFDocumentProxy | null;
|
||||||
|
currentPage: number;
|
||||||
|
totalPages: number;
|
||||||
|
isOpen: boolean;
|
||||||
|
container: HTMLElement | null;
|
||||||
|
}
|
||||||
+376
-347
@@ -1,177 +1,201 @@
|
|||||||
import { resetState } from './state.js';
|
import { resetState } from './state.js';
|
||||||
import { formatBytes, getPDFDocument } from './utils/helpers.js';
|
import { formatBytes, getPDFDocument } from './utils/helpers.js';
|
||||||
import { tesseractLanguages } from './config/tesseract-languages.js';
|
import { tesseractLanguages } from './config/tesseract-languages.js';
|
||||||
import { renderPagesProgressively, cleanupLazyRendering } from './utils/render-utils.js';
|
import {
|
||||||
|
renderPagesProgressively,
|
||||||
|
cleanupLazyRendering,
|
||||||
|
} from './utils/render-utils.js';
|
||||||
|
import { initPagePreview } from './utils/page-preview.js';
|
||||||
import { icons, createIcons } from 'lucide';
|
import { icons, createIcons } from 'lucide';
|
||||||
import Sortable from 'sortablejs';
|
import Sortable from 'sortablejs';
|
||||||
import { getRotationState, updateRotationState } from './utils/rotation-state.js';
|
import {
|
||||||
|
getRotationState,
|
||||||
|
updateRotationState,
|
||||||
|
} from './utils/rotation-state.js';
|
||||||
import * as pdfjsLib from 'pdfjs-dist';
|
import * as pdfjsLib from 'pdfjs-dist';
|
||||||
import { t } from './i18n/i18n';
|
import { t } from './i18n/i18n';
|
||||||
|
|
||||||
pdfjsLib.GlobalWorkerOptions.workerSrc = new URL('pdfjs-dist/build/pdf.worker.min.mjs', import.meta.url).toString();
|
pdfjsLib.GlobalWorkerOptions.workerSrc = new URL(
|
||||||
|
'pdfjs-dist/build/pdf.worker.min.mjs',
|
||||||
|
import.meta.url
|
||||||
|
).toString();
|
||||||
|
|
||||||
// Centralizing DOM element selection
|
// Centralizing DOM element selection
|
||||||
export const dom = {
|
export const dom = {
|
||||||
gridView: document.getElementById('grid-view'),
|
gridView: document.getElementById('grid-view'),
|
||||||
toolGrid: document.getElementById('tool-grid'),
|
toolGrid: document.getElementById('tool-grid'),
|
||||||
toolInterface: document.getElementById('tool-interface'),
|
toolInterface: document.getElementById('tool-interface'),
|
||||||
toolContent: document.getElementById('tool-content'),
|
toolContent: document.getElementById('tool-content'),
|
||||||
backToGridBtn: document.getElementById('back-to-grid'),
|
backToGridBtn: document.getElementById('back-to-grid'),
|
||||||
loaderModal: document.getElementById('loader-modal'),
|
loaderModal: document.getElementById('loader-modal'),
|
||||||
loaderText: document.getElementById('loader-text'),
|
loaderText: document.getElementById('loader-text'),
|
||||||
alertModal: document.getElementById('alert-modal'),
|
alertModal: document.getElementById('alert-modal'),
|
||||||
alertTitle: document.getElementById('alert-title'),
|
alertTitle: document.getElementById('alert-title'),
|
||||||
alertMessage: document.getElementById('alert-message'),
|
alertMessage: document.getElementById('alert-message'),
|
||||||
alertOkBtn: document.getElementById('alert-ok'),
|
alertOkBtn: document.getElementById('alert-ok'),
|
||||||
heroSection: document.getElementById('hero-section'),
|
heroSection: document.getElementById('hero-section'),
|
||||||
featuresSection: document.getElementById('features-section'),
|
featuresSection: document.getElementById('features-section'),
|
||||||
toolsHeader: document.getElementById('tools-header'),
|
toolsHeader: document.getElementById('tools-header'),
|
||||||
dividers: document.querySelectorAll('.section-divider'),
|
dividers: document.querySelectorAll('.section-divider'),
|
||||||
hideSections: document.querySelectorAll('.hide-section'),
|
hideSections: document.querySelectorAll('.hide-section'),
|
||||||
shortcutsModal: document.getElementById('shortcuts-modal'),
|
shortcutsModal: document.getElementById('shortcuts-modal'),
|
||||||
closeShortcutsModalBtn: document.getElementById('close-shortcuts-modal'),
|
closeShortcutsModalBtn: document.getElementById('close-shortcuts-modal'),
|
||||||
shortcutsList: document.getElementById('shortcuts-list'),
|
shortcutsList: document.getElementById('shortcuts-list'),
|
||||||
shortcutSearch: document.getElementById('shortcut-search'),
|
shortcutSearch: document.getElementById('shortcut-search'),
|
||||||
resetShortcutsBtn: document.getElementById('reset-shortcuts-btn'),
|
resetShortcutsBtn: document.getElementById('reset-shortcuts-btn'),
|
||||||
importShortcutsBtn: document.getElementById('import-shortcuts-btn'),
|
importShortcutsBtn: document.getElementById('import-shortcuts-btn'),
|
||||||
exportShortcutsBtn: document.getElementById('export-shortcuts-btn'),
|
exportShortcutsBtn: document.getElementById('export-shortcuts-btn'),
|
||||||
openShortcutsBtn: document.getElementById('open-shortcuts-btn'),
|
openShortcutsBtn: document.getElementById('open-shortcuts-btn'),
|
||||||
warningModal: document.getElementById('warning-modal'),
|
warningModal: document.getElementById('warning-modal'),
|
||||||
warningTitle: document.getElementById('warning-title'),
|
warningTitle: document.getElementById('warning-title'),
|
||||||
warningMessage: document.getElementById('warning-message'),
|
warningMessage: document.getElementById('warning-message'),
|
||||||
warningCancelBtn: document.getElementById('warning-cancel-btn'),
|
warningCancelBtn: document.getElementById('warning-cancel-btn'),
|
||||||
warningConfirmBtn: document.getElementById('warning-confirm-btn'),
|
warningConfirmBtn: document.getElementById('warning-confirm-btn'),
|
||||||
};
|
};
|
||||||
|
|
||||||
export const showLoader = (text = t('common.loading'), progress?: number) => {
|
export const showLoader = (text = t('common.loading'), progress?: number) => {
|
||||||
if (dom.loaderText) dom.loaderText.textContent = text;
|
if (dom.loaderText) dom.loaderText.textContent = text;
|
||||||
|
|
||||||
// Add or update progress bar if progress is provided
|
// Add or update progress bar if progress is provided
|
||||||
const loaderModal = dom.loaderModal;
|
const loaderModal = dom.loaderModal;
|
||||||
if (loaderModal) {
|
if (loaderModal) {
|
||||||
let progressBar = loaderModal.querySelector('.loader-progress-bar') as HTMLElement;
|
let progressBar = loaderModal.querySelector(
|
||||||
let progressContainer = loaderModal.querySelector('.loader-progress-container') as HTMLElement;
|
'.loader-progress-bar'
|
||||||
|
) as HTMLElement;
|
||||||
|
let progressContainer = loaderModal.querySelector(
|
||||||
|
'.loader-progress-container'
|
||||||
|
) as HTMLElement;
|
||||||
|
|
||||||
if (progress !== undefined && progress >= 0) {
|
if (progress !== undefined && progress >= 0) {
|
||||||
// Create progress container if it doesn't exist
|
// Create progress container if it doesn't exist
|
||||||
if (!progressContainer) {
|
if (!progressContainer) {
|
||||||
progressContainer = document.createElement('div');
|
progressContainer = document.createElement('div');
|
||||||
progressContainer.className = 'loader-progress-container w-64 mt-4';
|
progressContainer.className = 'loader-progress-container w-64 mt-4';
|
||||||
progressContainer.innerHTML = `
|
progressContainer.innerHTML = `
|
||||||
<div class="bg-gray-700 rounded-full h-2 overflow-hidden">
|
<div class="bg-gray-700 rounded-full h-2 overflow-hidden">
|
||||||
<div class="loader-progress-bar bg-indigo-500 h-full transition-all duration-300" style="width: 0%"></div>
|
<div class="loader-progress-bar bg-indigo-500 h-full transition-all duration-300" style="width: 0%"></div>
|
||||||
</div>
|
</div>
|
||||||
<p class="loader-progress-text text-xs text-gray-400 mt-1 text-center">0%</p>
|
<p class="loader-progress-text text-xs text-gray-400 mt-1 text-center">0%</p>
|
||||||
`;
|
`;
|
||||||
loaderModal.querySelector('.bg-gray-800')?.appendChild(progressContainer);
|
loaderModal
|
||||||
progressBar = progressContainer.querySelector('.loader-progress-bar') as HTMLElement;
|
.querySelector('.bg-gray-800')
|
||||||
}
|
?.appendChild(progressContainer);
|
||||||
|
progressBar = progressContainer.querySelector(
|
||||||
|
'.loader-progress-bar'
|
||||||
|
) as HTMLElement;
|
||||||
|
}
|
||||||
|
|
||||||
// Update progress
|
// Update progress
|
||||||
if (progressBar) {
|
if (progressBar) {
|
||||||
progressBar.style.width = `${progress}%`;
|
progressBar.style.width = `${progress}%`;
|
||||||
}
|
}
|
||||||
const progressText = progressContainer.querySelector('.loader-progress-text');
|
const progressText = progressContainer.querySelector(
|
||||||
if (progressText) {
|
'.loader-progress-text'
|
||||||
progressText.textContent = `${Math.round(progress)}%`;
|
);
|
||||||
}
|
if (progressText) {
|
||||||
progressContainer.classList.remove('hidden');
|
progressText.textContent = `${Math.round(progress)}%`;
|
||||||
} else {
|
}
|
||||||
// Hide progress bar if no progress provided
|
progressContainer.classList.remove('hidden');
|
||||||
if (progressContainer) {
|
} else {
|
||||||
progressContainer.classList.add('hidden');
|
// Hide progress bar if no progress provided
|
||||||
}
|
if (progressContainer) {
|
||||||
}
|
progressContainer.classList.add('hidden');
|
||||||
|
}
|
||||||
loaderModal.classList.remove('hidden');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
loaderModal.classList.remove('hidden');
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
export const hideLoader = () => {
|
export const hideLoader = () => {
|
||||||
if (dom.loaderModal) dom.loaderModal.classList.add('hidden');
|
if (dom.loaderModal) dom.loaderModal.classList.add('hidden');
|
||||||
};
|
};
|
||||||
|
|
||||||
export const showAlert = (title: any, message: any, type: string = 'error', callback?: () => void) => {
|
export const showAlert = (
|
||||||
if (dom.alertTitle) dom.alertTitle.textContent = title;
|
title: any,
|
||||||
if (dom.alertMessage) dom.alertMessage.textContent = message;
|
message: any,
|
||||||
if (dom.alertModal) dom.alertModal.classList.remove('hidden');
|
type: string = 'error',
|
||||||
|
callback?: () => void
|
||||||
|
) => {
|
||||||
|
if (dom.alertTitle) dom.alertTitle.textContent = title;
|
||||||
|
if (dom.alertMessage) dom.alertMessage.textContent = message;
|
||||||
|
if (dom.alertModal) dom.alertModal.classList.remove('hidden');
|
||||||
|
|
||||||
if (dom.alertOkBtn) {
|
if (dom.alertOkBtn) {
|
||||||
const newOkBtn = dom.alertOkBtn.cloneNode(true) as HTMLElement;
|
const newOkBtn = dom.alertOkBtn.cloneNode(true) as HTMLElement;
|
||||||
dom.alertOkBtn.replaceWith(newOkBtn);
|
dom.alertOkBtn.replaceWith(newOkBtn);
|
||||||
dom.alertOkBtn = newOkBtn;
|
dom.alertOkBtn = newOkBtn;
|
||||||
|
|
||||||
newOkBtn.addEventListener('click', () => {
|
newOkBtn.addEventListener('click', () => {
|
||||||
hideAlert();
|
hideAlert();
|
||||||
if (callback) callback();
|
if (callback) callback();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
export const hideAlert = () => {
|
export const hideAlert = () => {
|
||||||
if (dom.alertModal) dom.alertModal.classList.add('hidden');
|
if (dom.alertModal) dom.alertModal.classList.add('hidden');
|
||||||
};
|
};
|
||||||
|
|
||||||
export const switchView = (view: any) => {
|
export const switchView = (view: any) => {
|
||||||
if (view === 'grid') {
|
if (view === 'grid') {
|
||||||
dom.gridView.classList.remove('hidden');
|
dom.gridView.classList.remove('hidden');
|
||||||
dom.toolInterface.classList.add('hidden');
|
dom.toolInterface.classList.add('hidden');
|
||||||
// show hero and features and header
|
// show hero and features and header
|
||||||
dom.heroSection.classList.remove('hidden');
|
dom.heroSection.classList.remove('hidden');
|
||||||
dom.featuresSection.classList.remove('hidden');
|
dom.featuresSection.classList.remove('hidden');
|
||||||
dom.toolsHeader.classList.remove('hidden');
|
dom.toolsHeader.classList.remove('hidden');
|
||||||
// show dividers
|
// show dividers
|
||||||
dom.dividers.forEach((divider) => {
|
dom.dividers.forEach((divider) => {
|
||||||
divider.classList.remove('hidden');
|
divider.classList.remove('hidden');
|
||||||
});
|
});
|
||||||
// show hideSections
|
// show hideSections
|
||||||
dom.hideSections.forEach((section) => {
|
dom.hideSections.forEach((section) => {
|
||||||
section.classList.remove('hidden');
|
section.classList.remove('hidden');
|
||||||
});
|
});
|
||||||
|
|
||||||
resetState();
|
resetState();
|
||||||
} else {
|
} else {
|
||||||
dom.gridView.classList.add('hidden');
|
dom.gridView.classList.add('hidden');
|
||||||
dom.toolInterface.classList.remove('hidden');
|
dom.toolInterface.classList.remove('hidden');
|
||||||
dom.featuresSection.classList.add('hidden');
|
dom.featuresSection.classList.add('hidden');
|
||||||
dom.heroSection.classList.add('hidden');
|
dom.heroSection.classList.add('hidden');
|
||||||
dom.toolsHeader.classList.add('hidden');
|
dom.toolsHeader.classList.add('hidden');
|
||||||
dom.dividers.forEach((divider) => {
|
dom.dividers.forEach((divider) => {
|
||||||
divider.classList.add('hidden');
|
divider.classList.add('hidden');
|
||||||
});
|
});
|
||||||
dom.hideSections.forEach((section) => {
|
dom.hideSections.forEach((section) => {
|
||||||
section.classList.add('hidden');
|
section.classList.add('hidden');
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const thumbnailState = {
|
const thumbnailState = {
|
||||||
sortableInstances: {},
|
sortableInstances: {},
|
||||||
};
|
};
|
||||||
|
|
||||||
function initializeOrganizeSortable(containerId: any) {
|
function initializeOrganizeSortable(containerId: any) {
|
||||||
const container = document.getElementById(containerId);
|
const container = document.getElementById(containerId);
|
||||||
if (!container) return;
|
if (!container) return;
|
||||||
|
|
||||||
if (thumbnailState.sortableInstances[containerId]) {
|
if (thumbnailState.sortableInstances[containerId]) {
|
||||||
thumbnailState.sortableInstances[containerId].destroy();
|
thumbnailState.sortableInstances[containerId].destroy();
|
||||||
}
|
}
|
||||||
|
|
||||||
thumbnailState.sortableInstances[containerId] = Sortable.create(container, {
|
thumbnailState.sortableInstances[containerId] = Sortable.create(container, {
|
||||||
animation: 150,
|
animation: 150,
|
||||||
ghostClass: 'sortable-ghost',
|
ghostClass: 'sortable-ghost',
|
||||||
chosenClass: 'sortable-chosen',
|
chosenClass: 'sortable-chosen',
|
||||||
dragClass: 'sortable-drag',
|
dragClass: 'sortable-drag',
|
||||||
filter: '.delete-page-btn',
|
filter: '.delete-page-btn',
|
||||||
preventOnFilter: true,
|
preventOnFilter: true,
|
||||||
onStart: function (evt: any) {
|
onStart: function (evt: any) {
|
||||||
evt.item.style.opacity = '0.5';
|
evt.item.style.opacity = '0.5';
|
||||||
},
|
},
|
||||||
onEnd: function (evt: any) {
|
onEnd: function (evt: any) {
|
||||||
evt.item.style.opacity = '1';
|
evt.item.style.opacity = '1';
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -180,243 +204,247 @@ function initializeOrganizeSortable(containerId: any) {
|
|||||||
* @param {object} pdfDoc The loaded pdf-lib document instance.
|
* @param {object} pdfDoc The loaded pdf-lib document instance.
|
||||||
*/
|
*/
|
||||||
export const renderPageThumbnails = async (toolId: any, pdfDoc: any) => {
|
export const renderPageThumbnails = async (toolId: any, pdfDoc: any) => {
|
||||||
const containerId = toolId === 'organize' ? 'page-organizer' : toolId === 'delete-pages' ? 'delete-pages-preview' : 'page-rotator';
|
const containerId =
|
||||||
const container = document.getElementById(containerId);
|
toolId === 'organize'
|
||||||
if (!container) return;
|
? 'page-organizer'
|
||||||
|
: toolId === 'delete-pages'
|
||||||
|
? 'delete-pages-preview'
|
||||||
|
: 'page-rotator';
|
||||||
|
const container = document.getElementById(containerId);
|
||||||
|
if (!container) return;
|
||||||
|
|
||||||
container.innerHTML = '';
|
container.innerHTML = '';
|
||||||
|
|
||||||
// Cleanup any previous lazy loading observers
|
// Cleanup any previous lazy loading observers
|
||||||
cleanupLazyRendering();
|
cleanupLazyRendering();
|
||||||
|
|
||||||
const currentRenderId = Date.now();
|
const currentRenderId = Date.now();
|
||||||
container.dataset.renderId = currentRenderId.toString();
|
container.dataset.renderId = currentRenderId.toString();
|
||||||
|
|
||||||
showLoader(t('multiTool.renderingTitle'));
|
showLoader(t('multiTool.renderingTitle'));
|
||||||
|
|
||||||
const pdfData = await pdfDoc.save();
|
const pdfData = await pdfDoc.save();
|
||||||
const pdf = await getPDFDocument({ data: pdfData }).promise;
|
const pdf = await getPDFDocument({ data: pdfData }).promise;
|
||||||
|
|
||||||
// Function to create wrapper element for each page
|
// Function to create wrapper element for each page
|
||||||
const createWrapper = (canvas: HTMLCanvasElement, pageNumber: number) => {
|
const createWrapper = (canvas: HTMLCanvasElement, pageNumber: number) => {
|
||||||
const wrapper = document.createElement('div');
|
const wrapper = document.createElement('div');
|
||||||
// @ts-expect-error TS(2322) FIXME: Type 'number' is not assignable to type 'string'.
|
// @ts-expect-error TS(2322) FIXME: Type 'number' is not assignable to type 'string'.
|
||||||
wrapper.dataset.pageIndex = pageNumber - 1;
|
wrapper.dataset.pageIndex = pageNumber - 1;
|
||||||
|
|
||||||
const imgContainer = document.createElement('div');
|
const imgContainer = document.createElement('div');
|
||||||
imgContainer.className =
|
imgContainer.className = 'relative';
|
||||||
'w-full h-36 bg-gray-900 rounded-lg flex items-center justify-center overflow-hidden border-2 border-gray-600';
|
|
||||||
|
|
||||||
const img = document.createElement('img');
|
const img = document.createElement('img');
|
||||||
img.src = canvas.toDataURL();
|
img.src = canvas.toDataURL();
|
||||||
img.className = 'max-w-full max-h-full object-contain';
|
img.className = 'rounded-md shadow-md max-w-full h-auto';
|
||||||
|
|
||||||
imgContainer.appendChild(img);
|
imgContainer.appendChild(img);
|
||||||
|
|
||||||
if (toolId === 'organize') {
|
const pageNumSpan = document.createElement('div');
|
||||||
wrapper.className = 'page-thumbnail relative group';
|
pageNumSpan.className =
|
||||||
wrapper.appendChild(imgContainer);
|
'absolute top-1 left-1 bg-indigo-600 text-white text-xs px-2 py-1 rounded-md font-semibold shadow-lg z-10 pointer-events-none';
|
||||||
|
pageNumSpan.textContent = pageNumber.toString();
|
||||||
|
|
||||||
const pageNumSpan = document.createElement('span');
|
if (toolId === 'organize') {
|
||||||
pageNumSpan.className =
|
wrapper.className =
|
||||||
'absolute top-1 left-1 bg-gray-900 bg-opacity-75 text-white text-xs rounded-full px-2 py-1';
|
'page-thumbnail relative cursor-move flex flex-col items-center gap-1 p-2 border-2 border-gray-600 hover:border-indigo-500 rounded-lg bg-gray-700 transition-colors group';
|
||||||
pageNumSpan.textContent = pageNumber.toString();
|
|
||||||
|
|
||||||
const deleteBtn = document.createElement('button');
|
imgContainer.appendChild(pageNumSpan);
|
||||||
deleteBtn.className =
|
wrapper.appendChild(imgContainer);
|
||||||
'delete-page-btn absolute top-1 right-1 bg-red-600 text-white rounded-full w-6 h-6 flex items-center justify-center';
|
|
||||||
deleteBtn.innerHTML = '×';
|
|
||||||
deleteBtn.addEventListener('click', (e) => {
|
|
||||||
(e.currentTarget as HTMLElement).parentElement.remove();
|
|
||||||
|
|
||||||
// Renumber remaining pages
|
const deleteBtn = document.createElement('button');
|
||||||
const pages = container.querySelectorAll('.page-thumbnail');
|
deleteBtn.className =
|
||||||
pages.forEach((page, index) => {
|
'delete-page-btn absolute top-1 right-1 bg-red-600 hover:bg-red-500 text-white rounded-full w-6 h-6 flex items-center justify-center z-10';
|
||||||
const numSpan = page.querySelector('span');
|
deleteBtn.innerHTML = '×';
|
||||||
if (numSpan) {
|
deleteBtn.addEventListener('click', (e) => {
|
||||||
numSpan.textContent = (index + 1).toString();
|
(e.currentTarget as HTMLElement).parentElement.remove();
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
initializeOrganizeSortable(containerId);
|
// Renumber remaining pages
|
||||||
});
|
const pages = container.querySelectorAll('.page-thumbnail');
|
||||||
|
pages.forEach((page, index) => {
|
||||||
|
const numSpan = page.querySelector('.bg-indigo-600');
|
||||||
|
if (numSpan) {
|
||||||
|
numSpan.textContent = (index + 1).toString();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
wrapper.append(pageNumSpan, deleteBtn);
|
initializeOrganizeSortable(containerId);
|
||||||
} else if (toolId === 'rotate') {
|
});
|
||||||
wrapper.className = 'page-rotator-item flex flex-col items-center gap-2 relative group';
|
|
||||||
|
|
||||||
// Read rotation from state (handles "Rotate All" on lazy-loaded pages)
|
wrapper.appendChild(deleteBtn);
|
||||||
const rotationStateArray = getRotationState();
|
} else if (toolId === 'rotate') {
|
||||||
const pageIndex = pageNumber - 1;
|
wrapper.className =
|
||||||
const initialRotation = rotationStateArray[pageIndex] || 0;
|
'page-rotator-item flex flex-col items-center gap-2 p-2 border-2 border-gray-600 hover:border-indigo-500 rounded-lg bg-gray-700 transition-colors relative group';
|
||||||
|
|
||||||
wrapper.dataset.rotation = initialRotation.toString();
|
// Read rotation from state (handles "Rotate All" on lazy-loaded pages)
|
||||||
img.classList.add('transition-transform', 'duration-300');
|
const rotationStateArray = getRotationState();
|
||||||
|
const pageIndex = pageNumber - 1;
|
||||||
|
const initialRotation = rotationStateArray[pageIndex] || 0;
|
||||||
|
|
||||||
// Apply initial rotation if any
|
wrapper.dataset.rotation = initialRotation.toString();
|
||||||
if (initialRotation !== 0) {
|
img.classList.add('transition-transform', 'duration-300');
|
||||||
img.style.transform = `rotate(${initialRotation}deg)`;
|
|
||||||
}
|
|
||||||
|
|
||||||
wrapper.appendChild(imgContainer);
|
// Apply initial rotation if any
|
||||||
|
if (initialRotation !== 0) {
|
||||||
|
img.style.transform = `rotate(${initialRotation}deg)`;
|
||||||
|
}
|
||||||
|
|
||||||
// Page Number Overlay (Top Left)
|
imgContainer.appendChild(pageNumSpan);
|
||||||
const pageNumSpan = document.createElement('span');
|
wrapper.appendChild(imgContainer);
|
||||||
pageNumSpan.className =
|
|
||||||
'absolute top-2 left-2 bg-gray-900 bg-opacity-75 text-white text-xs font-medium rounded-md px-2 py-1 shadow-sm z-10 pointer-events-none';
|
|
||||||
pageNumSpan.textContent = pageNumber.toString();
|
|
||||||
wrapper.appendChild(pageNumSpan);
|
|
||||||
|
|
||||||
const controlsDiv = document.createElement('div');
|
const controlsDiv = document.createElement('div');
|
||||||
controlsDiv.className = 'flex flex-col lg:flex-row items-center justify-center w-full gap-2 px-1';
|
controlsDiv.className =
|
||||||
|
'flex flex-col lg:flex-row items-center justify-center w-full gap-2 px-1';
|
||||||
|
|
||||||
// Custom Stepper Component
|
// Custom Stepper Component
|
||||||
const stepperContainer = document.createElement('div');
|
const stepperContainer = document.createElement('div');
|
||||||
stepperContainer.className = 'flex items-center border border-gray-600 rounded-md bg-gray-800 overflow-hidden w-24 h-8';
|
stepperContainer.className =
|
||||||
|
'flex items-center border border-gray-600 rounded-md bg-gray-800 overflow-hidden w-24 h-8';
|
||||||
|
|
||||||
const decrementBtn = document.createElement('button');
|
const decrementBtn = document.createElement('button');
|
||||||
decrementBtn.className = 'px-2 h-full text-gray-400 hover:text-white hover:bg-gray-700 border-r border-gray-600 transition-colors flex items-center justify-center';
|
decrementBtn.className =
|
||||||
decrementBtn.innerHTML = '<i data-lucide="minus" class="w-3 h-3"></i>';
|
'px-2 h-full text-gray-400 hover:text-white hover:bg-gray-700 border-r border-gray-600 transition-colors flex items-center justify-center';
|
||||||
|
decrementBtn.innerHTML = '<i data-lucide="minus" class="w-3 h-3"></i>';
|
||||||
|
|
||||||
const angleInput = document.createElement('input');
|
const angleInput = document.createElement('input');
|
||||||
angleInput.type = 'number';
|
angleInput.type = 'number';
|
||||||
angleInput.className = 'no-spinner w-full h-full bg-transparent text-white text-xs text-center focus:outline-none appearance-none m-0 p-0 border-none';
|
angleInput.className =
|
||||||
angleInput.value = initialRotation.toString();
|
'no-spinner w-full h-full bg-transparent text-white text-xs text-center focus:outline-none appearance-none m-0 p-0 border-none';
|
||||||
angleInput.placeholder = "0";
|
angleInput.value = initialRotation.toString();
|
||||||
|
angleInput.placeholder = '0';
|
||||||
|
|
||||||
const incrementBtn = document.createElement('button');
|
const incrementBtn = document.createElement('button');
|
||||||
incrementBtn.className = 'px-2 h-full text-gray-400 hover:text-white hover:bg-gray-700 border-l border-gray-600 transition-colors flex items-center justify-center';
|
incrementBtn.className =
|
||||||
incrementBtn.innerHTML = '<i data-lucide="plus" class="w-3 h-3"></i>';
|
'px-2 h-full text-gray-400 hover:text-white hover:bg-gray-700 border-l border-gray-600 transition-colors flex items-center justify-center';
|
||||||
|
incrementBtn.innerHTML = '<i data-lucide="plus" class="w-3 h-3"></i>';
|
||||||
|
|
||||||
// Helper to update rotation
|
// Helper to update rotation
|
||||||
const updateRotation = (newRotation: number) => {
|
const updateRotation = (newRotation: number) => {
|
||||||
const card = wrapper; // Closure capture
|
const card = wrapper; // Closure capture
|
||||||
const imgEl = card.querySelector('img');
|
const imgEl = card.querySelector('img');
|
||||||
const pageIndex = pageNumber - 1;
|
const pageIndex = pageNumber - 1;
|
||||||
|
|
||||||
// Update UI
|
// Update UI
|
||||||
angleInput.value = newRotation.toString();
|
angleInput.value = newRotation.toString();
|
||||||
card.dataset.rotation = newRotation.toString();
|
card.dataset.rotation = newRotation.toString();
|
||||||
imgEl.style.transform = `rotate(${newRotation}deg)`;
|
imgEl.style.transform = `rotate(${newRotation}deg)`;
|
||||||
|
|
||||||
// Update State
|
// Update State
|
||||||
updateRotationState(pageIndex, newRotation);
|
updateRotationState(pageIndex, newRotation);
|
||||||
};
|
};
|
||||||
|
|
||||||
// Event Listeners
|
// Event Listeners
|
||||||
decrementBtn.addEventListener('click', (e) => {
|
decrementBtn.addEventListener('click', (e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
const current = parseInt(angleInput.value) || 0;
|
const current = parseInt(angleInput.value) || 0;
|
||||||
updateRotation(current - 1);
|
updateRotation(current - 1);
|
||||||
});
|
});
|
||||||
|
|
||||||
incrementBtn.addEventListener('click', (e) => {
|
incrementBtn.addEventListener('click', (e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
const current = parseInt(angleInput.value) || 0;
|
const current = parseInt(angleInput.value) || 0;
|
||||||
updateRotation(current + 1);
|
updateRotation(current + 1);
|
||||||
});
|
});
|
||||||
|
|
||||||
angleInput.addEventListener('change', (e) => {
|
angleInput.addEventListener('change', (e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
const val = parseInt((e.target as HTMLInputElement).value) || 0;
|
const val = parseInt((e.target as HTMLInputElement).value) || 0;
|
||||||
updateRotation(val);
|
updateRotation(val);
|
||||||
});
|
});
|
||||||
angleInput.addEventListener('click', (e) => e.stopPropagation());
|
angleInput.addEventListener('click', (e) => e.stopPropagation());
|
||||||
|
|
||||||
stepperContainer.append(decrementBtn, angleInput, incrementBtn);
|
stepperContainer.append(decrementBtn, angleInput, incrementBtn);
|
||||||
|
|
||||||
const rotateBtn = document.createElement('button');
|
const rotateBtn = document.createElement('button');
|
||||||
rotateBtn.className = 'rotate-btn btn bg-gray-700 hover:bg-gray-600 p-1.5 rounded-md text-gray-200 transition-colors flex-shrink-0';
|
rotateBtn.className =
|
||||||
rotateBtn.title = 'Rotate +90°';
|
'rotate-btn btn bg-gray-700 hover:bg-gray-600 p-1.5 rounded-md text-gray-200 transition-colors flex-shrink-0';
|
||||||
rotateBtn.innerHTML = '<i data-lucide="rotate-cw" class="w-4 h-4"></i>';
|
rotateBtn.title = 'Rotate +90°';
|
||||||
rotateBtn.addEventListener('click', (e) => {
|
rotateBtn.innerHTML = '<i data-lucide="rotate-cw" class="w-4 h-4"></i>';
|
||||||
e.stopPropagation();
|
rotateBtn.addEventListener('click', (e) => {
|
||||||
const current = parseInt(angleInput.value) || 0;
|
e.stopPropagation();
|
||||||
updateRotation(current + 90);
|
const current = parseInt(angleInput.value) || 0;
|
||||||
});
|
updateRotation(current + 90);
|
||||||
|
});
|
||||||
|
|
||||||
controlsDiv.append(stepperContainer, rotateBtn);
|
controlsDiv.append(stepperContainer, rotateBtn);
|
||||||
wrapper.appendChild(controlsDiv);
|
wrapper.appendChild(controlsDiv);
|
||||||
} else if (toolId === 'delete-pages') {
|
} else if (toolId === 'delete-pages') {
|
||||||
wrapper.className = 'page-thumbnail relative group cursor-pointer transition-all duration-200';
|
wrapper.className =
|
||||||
wrapper.dataset.pageNumber = pageNumber.toString();
|
'page-thumbnail relative cursor-pointer flex flex-col items-center gap-1 p-2 border-2 border-gray-600 hover:border-indigo-500 rounded-lg bg-gray-700 transition-colors group';
|
||||||
|
wrapper.dataset.pageNumber = pageNumber.toString();
|
||||||
|
|
||||||
const innerContainer = document.createElement('div');
|
imgContainer.appendChild(pageNumSpan);
|
||||||
innerContainer.className = 'relative w-full h-36 bg-gray-900 rounded-lg flex items-center justify-center overflow-hidden border-2 border-gray-600 transition-colors duration-200';
|
wrapper.appendChild(imgContainer);
|
||||||
innerContainer.appendChild(img);
|
|
||||||
wrapper.appendChild(innerContainer);
|
|
||||||
|
|
||||||
const pageNumSpan = document.createElement('span');
|
wrapper.addEventListener('click', () => {
|
||||||
pageNumSpan.className =
|
const input = document.getElementById(
|
||||||
'absolute top-2 left-2 bg-gray-900 bg-opacity-75 text-white text-xs font-medium rounded-md px-2 py-1 shadow-sm z-10 pointer-events-none';
|
'pages-to-delete'
|
||||||
pageNumSpan.textContent = pageNumber.toString();
|
) as HTMLInputElement;
|
||||||
wrapper.appendChild(pageNumSpan);
|
if (!input) return;
|
||||||
|
|
||||||
wrapper.addEventListener('click', () => {
|
const currentVal = input.value;
|
||||||
const input = document.getElementById('pages-to-delete') as HTMLInputElement;
|
let pages = currentVal
|
||||||
if (!input) return;
|
.split(',')
|
||||||
|
.map((s) => s.trim())
|
||||||
|
.filter((s) => s);
|
||||||
|
const pageStr = pageNumber.toString();
|
||||||
|
|
||||||
const currentVal = input.value;
|
if (pages.includes(pageStr)) {
|
||||||
let pages = currentVal.split(',').map(s => s.trim()).filter(s => s);
|
pages = pages.filter((p) => p !== pageStr);
|
||||||
const pageStr = pageNumber.toString();
|
} else {
|
||||||
|
pages.push(pageStr);
|
||||||
if (pages.includes(pageStr)) {
|
|
||||||
pages = pages.filter(p => p !== pageStr);
|
|
||||||
} else {
|
|
||||||
pages.push(pageStr);
|
|
||||||
}
|
|
||||||
|
|
||||||
pages.sort((a, b) => {
|
|
||||||
const numA = parseInt(a.split('-')[0]);
|
|
||||||
const numB = parseInt(b.split('-')[0]);
|
|
||||||
return numA - numB;
|
|
||||||
});
|
|
||||||
|
|
||||||
input.value = pages.join(', ');
|
|
||||||
|
|
||||||
input.dispatchEvent(new Event('input'));
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return wrapper;
|
pages.sort((a, b) => {
|
||||||
};
|
const numA = parseInt(a.split('-')[0]);
|
||||||
|
const numB = parseInt(b.split('-')[0]);
|
||||||
|
return numA - numB;
|
||||||
|
});
|
||||||
|
|
||||||
try {
|
input.value = pages.join(', ');
|
||||||
// Render pages progressively with lazy loading
|
|
||||||
await renderPagesProgressively(
|
|
||||||
pdf,
|
|
||||||
container,
|
|
||||||
createWrapper,
|
|
||||||
{
|
|
||||||
batchSize: 8,
|
|
||||||
useLazyLoading: true,
|
|
||||||
lazyLoadMargin: '300px',
|
|
||||||
onProgress: (current, total) => {
|
|
||||||
showLoader(`Rendering page previews: ${current}/${total}`);
|
|
||||||
},
|
|
||||||
onBatchComplete: () => {
|
|
||||||
createIcons({ icons });
|
|
||||||
},
|
|
||||||
shouldCancel: () => {
|
|
||||||
return container.dataset.renderId !== currentRenderId.toString();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
if (toolId === 'organize') {
|
input.dispatchEvent(new Event('input'));
|
||||||
initializeOrganizeSortable(containerId);
|
});
|
||||||
} else if (toolId === 'delete-pages') {
|
|
||||||
// No sortable needed for delete pages
|
|
||||||
}
|
|
||||||
|
|
||||||
// Reinitialize lucide icons for dynamically added elements
|
|
||||||
createIcons({ icons });
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error rendering page thumbnails:', error);
|
|
||||||
showAlert(t('multiTool.error'), t('multiTool.errorRendering'));
|
|
||||||
} finally {
|
|
||||||
hideLoader();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return wrapper;
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Render pages progressively with lazy loading
|
||||||
|
await renderPagesProgressively(pdf, container, createWrapper, {
|
||||||
|
batchSize: 8,
|
||||||
|
useLazyLoading: true,
|
||||||
|
lazyLoadMargin: '300px',
|
||||||
|
onProgress: (current, total) => {
|
||||||
|
showLoader(`Rendering page previews: ${current}/${total}`);
|
||||||
|
},
|
||||||
|
onBatchComplete: () => {
|
||||||
|
createIcons({ icons });
|
||||||
|
},
|
||||||
|
shouldCancel: () => {
|
||||||
|
return container.dataset.renderId !== currentRenderId.toString();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (toolId === 'organize') {
|
||||||
|
initializeOrganizeSortable(containerId);
|
||||||
|
} else if (toolId === 'delete-pages') {
|
||||||
|
// No sortable needed for delete pages
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reinitialize lucide icons for dynamically added elements
|
||||||
|
createIcons({ icons });
|
||||||
|
|
||||||
|
// Attach Quick Look page preview
|
||||||
|
initPagePreview(container, pdf);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error rendering page thumbnails:', error);
|
||||||
|
showAlert(t('multiTool.error'), t('multiTool.errorRendering'));
|
||||||
|
} finally {
|
||||||
|
hideLoader();
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -425,36 +453,36 @@ export const renderPageThumbnails = async (toolId: any, pdfDoc: any) => {
|
|||||||
* @param {File[]} files The array of file objects.
|
* @param {File[]} files The array of file objects.
|
||||||
*/
|
*/
|
||||||
export const renderFileDisplay = (container: any, files: any) => {
|
export const renderFileDisplay = (container: any, files: any) => {
|
||||||
container.textContent = '';
|
container.textContent = '';
|
||||||
if (files.length > 0) {
|
if (files.length > 0) {
|
||||||
files.forEach((file: any) => {
|
files.forEach((file: any) => {
|
||||||
const fileDiv = document.createElement('div');
|
const fileDiv = document.createElement('div');
|
||||||
fileDiv.className =
|
fileDiv.className =
|
||||||
'flex items-center justify-between bg-gray-700 p-3 rounded-lg text-sm';
|
'flex items-center justify-between bg-gray-700 p-3 rounded-lg text-sm';
|
||||||
|
|
||||||
const nameSpan = document.createElement('span');
|
const nameSpan = document.createElement('span');
|
||||||
nameSpan.className = 'truncate font-medium text-gray-200';
|
nameSpan.className = 'truncate font-medium text-gray-200';
|
||||||
nameSpan.textContent = file.name;
|
nameSpan.textContent = file.name;
|
||||||
|
|
||||||
const sizeSpan = document.createElement('span');
|
const sizeSpan = document.createElement('span');
|
||||||
sizeSpan.className = 'flex-shrink-0 ml-4 text-gray-400';
|
sizeSpan.className = 'flex-shrink-0 ml-4 text-gray-400';
|
||||||
sizeSpan.textContent = formatBytes(file.size);
|
sizeSpan.textContent = formatBytes(file.size);
|
||||||
|
|
||||||
fileDiv.append(nameSpan, sizeSpan);
|
fileDiv.append(nameSpan, sizeSpan);
|
||||||
container.appendChild(fileDiv);
|
container.appendChild(fileDiv);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const createFileInputHTML = (options = {}) => {
|
const createFileInputHTML = (options = {}) => {
|
||||||
// @ts-expect-error TS(2339) FIXME: Property 'multiple' does not exist on type '{}'.
|
// @ts-expect-error TS(2339) FIXME: Property 'multiple' does not exist on type '{}'.
|
||||||
const multiple = options.multiple ? 'multiple' : '';
|
const multiple = options.multiple ? 'multiple' : '';
|
||||||
// @ts-expect-error TS(2339) FIXME: Property 'accept' does not exist on type '{}'.
|
// @ts-expect-error TS(2339) FIXME: Property 'accept' does not exist on type '{}'.
|
||||||
const acceptedFiles = options.accept || 'application/pdf';
|
const acceptedFiles = options.accept || 'application/pdf';
|
||||||
// @ts-expect-error TS(2339) FIXME: Property 'showControls' does not exist on type '{}... Remove this comment to see the full error message
|
// @ts-expect-error TS(2339) FIXME: Property 'showControls' does not exist on type '{}... Remove this comment to see the full error message
|
||||||
const showControls = options.showControls || false; // NEW: Add this parameter
|
const showControls = options.showControls || false; // NEW: Add this parameter
|
||||||
|
|
||||||
return `
|
return `
|
||||||
<div id="drop-zone" class="relative flex flex-col items-center justify-center w-full h-48 border-2 border-dashed border-gray-600 rounded-xl cursor-pointer bg-gray-900 hover:bg-gray-700 transition-colors duration-300">
|
<div id="drop-zone" class="relative flex flex-col items-center justify-center w-full h-48 border-2 border-dashed border-gray-600 rounded-xl cursor-pointer bg-gray-900 hover:bg-gray-700 transition-colors duration-300">
|
||||||
<div class="flex flex-col items-center justify-center pt-5 pb-6">
|
<div class="flex flex-col items-center justify-center pt-5 pb-6">
|
||||||
<i data-lucide="upload-cloud" class="w-10 h-10 mb-3 text-gray-400"></i>
|
<i data-lucide="upload-cloud" class="w-10 h-10 mb-3 text-gray-400"></i>
|
||||||
@@ -465,7 +493,8 @@ const createFileInputHTML = (options = {}) => {
|
|||||||
<input id="file-input" type="file" class="absolute top-0 left-0 w-full h-full opacity-0 cursor-pointer" ${multiple} accept="${acceptedFiles}">
|
<input id="file-input" type="file" class="absolute top-0 left-0 w-full h-full opacity-0 cursor-pointer" ${multiple} accept="${acceptedFiles}">
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
${showControls
|
${
|
||||||
|
showControls
|
||||||
? `
|
? `
|
||||||
<!-- NEW: Add control buttons for multi-file uploads -->
|
<!-- NEW: Add control buttons for multi-file uploads -->
|
||||||
<div id="file-controls" class="hidden mt-4 flex gap-3">
|
<div id="file-controls" class="hidden mt-4 flex gap-3">
|
||||||
|
|||||||
@@ -0,0 +1,215 @@
|
|||||||
|
import * as pdfjsLib from 'pdfjs-dist';
|
||||||
|
import type { PDFDocumentProxy } from 'pdfjs-dist';
|
||||||
|
import { PreviewState } from '@/types';
|
||||||
|
|
||||||
|
pdfjsLib.GlobalWorkerOptions.workerSrc = new URL(
|
||||||
|
'pdfjs-dist/build/pdf.worker.min.mjs',
|
||||||
|
import.meta.url
|
||||||
|
).toString();
|
||||||
|
|
||||||
|
const state: PreviewState = {
|
||||||
|
modal: null,
|
||||||
|
pdfjsDoc: null,
|
||||||
|
currentPage: 1,
|
||||||
|
totalPages: 0,
|
||||||
|
isOpen: false,
|
||||||
|
container: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
function getOrCreateModal(): HTMLElement {
|
||||||
|
if (state.modal) return state.modal;
|
||||||
|
|
||||||
|
const modal = document.createElement('div');
|
||||||
|
modal.id = 'page-preview-modal';
|
||||||
|
modal.className =
|
||||||
|
'fixed inset-0 bg-black/80 backdrop-blur-sm z-[60] flex items-center justify-center opacity-0 pointer-events-none transition-opacity duration-200';
|
||||||
|
modal.innerHTML = `
|
||||||
|
<button id="preview-close" class="absolute top-4 right-4 text-white/70 hover:text-white z-10 transition-colors" title="Close (Esc)">
|
||||||
|
<svg class="w-8 h-8" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12"/></svg>
|
||||||
|
</button>
|
||||||
|
<button id="preview-prev" class="absolute left-4 top-1/2 -translate-y-1/2 text-white/50 hover:text-white transition-colors p-2" title="Previous page">
|
||||||
|
<svg class="w-10 h-10" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M15 19l-7-7 7-7"/></svg>
|
||||||
|
</button>
|
||||||
|
<button id="preview-next" class="absolute right-4 top-1/2 -translate-y-1/2 text-white/50 hover:text-white transition-colors p-2" title="Next page">
|
||||||
|
<svg class="w-10 h-10" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M9 5l7 7-7 7"/></svg>
|
||||||
|
</button>
|
||||||
|
<div id="preview-canvas-container" class="flex items-center justify-center max-w-[90vw] max-h-[85vh]">
|
||||||
|
<div id="preview-loading" class="text-white/60 text-sm">Loading...</div>
|
||||||
|
</div>
|
||||||
|
<div id="preview-page-info" class="absolute bottom-6 left-1/2 -translate-x-1/2 bg-gray-900/80 text-white text-sm px-4 py-2 rounded-full backdrop-blur-sm"></div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
modal.addEventListener('click', (e) => {
|
||||||
|
if (e.target === modal) hidePreview();
|
||||||
|
});
|
||||||
|
modal.querySelector('#preview-close')!.addEventListener('click', hidePreview);
|
||||||
|
modal
|
||||||
|
.querySelector('#preview-prev')!
|
||||||
|
.addEventListener('click', () => navigatePage(-1));
|
||||||
|
modal
|
||||||
|
.querySelector('#preview-next')!
|
||||||
|
.addEventListener('click', () => navigatePage(1));
|
||||||
|
|
||||||
|
document.body.appendChild(modal);
|
||||||
|
state.modal = modal;
|
||||||
|
return modal;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function renderPreviewPage(pageNumber: number): Promise<void> {
|
||||||
|
if (!state.pdfjsDoc) return;
|
||||||
|
|
||||||
|
const modal = getOrCreateModal();
|
||||||
|
const container = modal.querySelector(
|
||||||
|
'#preview-canvas-container'
|
||||||
|
) as HTMLElement;
|
||||||
|
const pageInfo = modal.querySelector('#preview-page-info') as HTMLElement;
|
||||||
|
const prevBtn = modal.querySelector('#preview-prev') as HTMLElement;
|
||||||
|
const nextBtn = modal.querySelector('#preview-next') as HTMLElement;
|
||||||
|
|
||||||
|
container.innerHTML = '<div class="text-white/60 text-sm">Loading...</div>';
|
||||||
|
|
||||||
|
pageInfo.textContent = `Page ${pageNumber} of ${state.totalPages}`;
|
||||||
|
prevBtn.style.visibility = pageNumber > 1 ? 'visible' : 'hidden';
|
||||||
|
nextBtn.style.visibility =
|
||||||
|
pageNumber < state.totalPages ? 'visible' : 'hidden';
|
||||||
|
|
||||||
|
try {
|
||||||
|
const page = await state.pdfjsDoc.getPage(pageNumber);
|
||||||
|
const scale = 2.0;
|
||||||
|
const viewport = page.getViewport({ scale });
|
||||||
|
|
||||||
|
const canvas = document.createElement('canvas');
|
||||||
|
canvas.width = viewport.width;
|
||||||
|
canvas.height = viewport.height;
|
||||||
|
canvas.className =
|
||||||
|
'max-w-[90vw] max-h-[85vh] object-contain rounded-lg shadow-2xl';
|
||||||
|
canvas.style.width = 'auto';
|
||||||
|
canvas.style.height = 'auto';
|
||||||
|
canvas.style.maxWidth = '90vw';
|
||||||
|
canvas.style.maxHeight = '85vh';
|
||||||
|
|
||||||
|
const ctx = canvas.getContext('2d')!;
|
||||||
|
await page.render({ canvasContext: ctx, viewport, canvas }).promise;
|
||||||
|
|
||||||
|
container.innerHTML = '';
|
||||||
|
container.appendChild(canvas);
|
||||||
|
state.currentPage = pageNumber;
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Preview render error:', err);
|
||||||
|
container.innerHTML =
|
||||||
|
'<div class="text-red-400 text-sm">Failed to render page</div>';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function navigatePage(delta: number): void {
|
||||||
|
const newPage = state.currentPage + delta;
|
||||||
|
if (newPage >= 1 && newPage <= state.totalPages) {
|
||||||
|
renderPreviewPage(newPage);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function showPreview(
|
||||||
|
pdfjsDoc: PDFDocumentProxy,
|
||||||
|
pageNumber: number,
|
||||||
|
totalPages: number
|
||||||
|
): void {
|
||||||
|
state.pdfjsDoc = pdfjsDoc;
|
||||||
|
state.totalPages = totalPages;
|
||||||
|
state.isOpen = true;
|
||||||
|
|
||||||
|
const modal = getOrCreateModal();
|
||||||
|
modal.classList.remove('opacity-0', 'pointer-events-none');
|
||||||
|
document.body.style.overflow = 'hidden';
|
||||||
|
|
||||||
|
renderPreviewPage(pageNumber);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function hidePreview(): void {
|
||||||
|
if (!state.modal) return;
|
||||||
|
state.isOpen = false;
|
||||||
|
state.modal.classList.add('opacity-0', 'pointer-events-none');
|
||||||
|
document.body.style.overflow = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleKeydown(e: KeyboardEvent): void {
|
||||||
|
if (!state.isOpen) return;
|
||||||
|
|
||||||
|
switch (e.key) {
|
||||||
|
case 'Escape':
|
||||||
|
hidePreview();
|
||||||
|
break;
|
||||||
|
case 'ArrowLeft':
|
||||||
|
e.preventDefault();
|
||||||
|
navigatePage(-1);
|
||||||
|
break;
|
||||||
|
case 'ArrowRight':
|
||||||
|
e.preventDefault();
|
||||||
|
navigatePage(1);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener('keydown', handleKeydown);
|
||||||
|
|
||||||
|
export function initPagePreview(
|
||||||
|
container: HTMLElement,
|
||||||
|
pdfjsDoc: PDFDocumentProxy,
|
||||||
|
options: { pageAttr?: string } = {}
|
||||||
|
): void {
|
||||||
|
const totalPages = pdfjsDoc.numPages;
|
||||||
|
|
||||||
|
const thumbnails = container.querySelectorAll<HTMLElement>(
|
||||||
|
'[data-page-number], [data-page-index], [data-pageIndex]'
|
||||||
|
);
|
||||||
|
|
||||||
|
thumbnails.forEach((thumb) => {
|
||||||
|
if (thumb.dataset.previewInit) return;
|
||||||
|
thumb.dataset.previewInit = 'true';
|
||||||
|
|
||||||
|
let pageNum = 1;
|
||||||
|
if (thumb.dataset.pageNumber) {
|
||||||
|
pageNum = parseInt(thumb.dataset.pageNumber, 10);
|
||||||
|
} else if (thumb.dataset.pageIndex !== undefined) {
|
||||||
|
pageNum = parseInt(thumb.dataset.pageIndex, 10) + 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
const icon = document.createElement('button');
|
||||||
|
icon.className =
|
||||||
|
'page-preview-btn absolute bottom-1 right-1 bg-gray-900/80 hover:bg-indigo-600 text-white/70 hover:text-white rounded-full w-7 h-7 flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity z-10';
|
||||||
|
icon.title = 'Preview';
|
||||||
|
icon.innerHTML =
|
||||||
|
'<svg class="w-4 h-4" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"/></svg>';
|
||||||
|
icon.addEventListener('click', (e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
e.preventDefault();
|
||||||
|
showPreview(pdfjsDoc, pageNum, totalPages);
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!thumb.classList.contains('relative')) {
|
||||||
|
thumb.classList.add('relative');
|
||||||
|
}
|
||||||
|
if (!thumb.classList.contains('group')) {
|
||||||
|
thumb.classList.add('group');
|
||||||
|
}
|
||||||
|
|
||||||
|
thumb.appendChild(icon);
|
||||||
|
});
|
||||||
|
|
||||||
|
container.addEventListener('keydown', (e) => {
|
||||||
|
if (e.key === ' ' && !state.isOpen) {
|
||||||
|
const hovered = container.querySelector<HTMLElement>(
|
||||||
|
'[data-preview-init]:hover'
|
||||||
|
);
|
||||||
|
if (hovered) {
|
||||||
|
e.preventDefault();
|
||||||
|
let pageNum = 1;
|
||||||
|
if (hovered.dataset.pageNumber) {
|
||||||
|
pageNum = parseInt(hovered.dataset.pageNumber, 10);
|
||||||
|
} else if (hovered.dataset.pageIndex !== undefined) {
|
||||||
|
pageNum = parseInt(hovered.dataset.pageIndex, 10) + 1;
|
||||||
|
}
|
||||||
|
showPreview(pdfjsDoc, pageNum, totalPages);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -12,7 +12,7 @@ export async function mergePdfs(
|
|||||||
);
|
);
|
||||||
copiedPages.forEach((page) => mergedDoc.addPage(page));
|
copiedPages.forEach((page) => mergedDoc.addPage(page));
|
||||||
}
|
}
|
||||||
return new Uint8Array(await mergedDoc.save());
|
return new Uint8Array(await mergedDoc.save({ addDefaultPage: false }));
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function splitPdf(
|
export async function splitPdf(
|
||||||
@@ -187,6 +187,9 @@ export interface TextWatermarkOptions {
|
|||||||
color: { r: number; g: number; b: number };
|
color: { r: number; g: number; b: number };
|
||||||
opacity: number;
|
opacity: number;
|
||||||
angle: number;
|
angle: number;
|
||||||
|
x?: number;
|
||||||
|
y?: number;
|
||||||
|
pageIndices?: number[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function addTextWatermark(
|
export async function addTextWatermark(
|
||||||
@@ -194,19 +197,60 @@ export async function addTextWatermark(
|
|||||||
options: TextWatermarkOptions
|
options: TextWatermarkOptions
|
||||||
): Promise<Uint8Array> {
|
): Promise<Uint8Array> {
|
||||||
const pdfDoc = await PDFDocument.load(pdfBytes);
|
const pdfDoc = await PDFDocument.load(pdfBytes);
|
||||||
const font = await pdfDoc.embedFont(StandardFonts.Helvetica);
|
|
||||||
|
const canvas = document.createElement('canvas');
|
||||||
|
const ctx = canvas.getContext('2d');
|
||||||
|
if (!ctx) throw new Error('Failed to create canvas context');
|
||||||
|
|
||||||
|
const dpr = 2;
|
||||||
|
const colorR = Math.round(options.color.r * 255);
|
||||||
|
const colorG = Math.round(options.color.g * 255);
|
||||||
|
const colorB = Math.round(options.color.b * 255);
|
||||||
|
const fontStr = `bold ${options.fontSize * dpr}px "Noto Sans SC", "Noto Sans JP", "Noto Sans KR", "Noto Sans Arabic", Arial, sans-serif`;
|
||||||
|
|
||||||
|
ctx.font = fontStr;
|
||||||
|
const metrics = ctx.measureText(options.text);
|
||||||
|
|
||||||
|
canvas.width = Math.ceil(metrics.width) + 4;
|
||||||
|
canvas.height = Math.ceil(options.fontSize * dpr * 1.4);
|
||||||
|
|
||||||
|
ctx.font = fontStr;
|
||||||
|
ctx.fillStyle = `rgb(${colorR}, ${colorG}, ${colorB})`;
|
||||||
|
ctx.textBaseline = 'middle';
|
||||||
|
ctx.fillText(options.text, 2, canvas.height / 2);
|
||||||
|
|
||||||
|
const blob = await new Promise<Blob>((resolve, reject) => {
|
||||||
|
canvas.toBlob(
|
||||||
|
(b) => (b ? resolve(b) : reject(new Error('Canvas toBlob failed'))),
|
||||||
|
'image/png'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
const imageBytes = new Uint8Array(await blob.arrayBuffer());
|
||||||
|
|
||||||
|
const image = await pdfDoc.embedPng(imageBytes);
|
||||||
const pages = pdfDoc.getPages();
|
const pages = pdfDoc.getPages();
|
||||||
|
const posX = options.x ?? 0.5;
|
||||||
|
const posY = options.y ?? 0.5;
|
||||||
|
const imgWidth = image.width / dpr;
|
||||||
|
const imgHeight = image.height / dpr;
|
||||||
|
|
||||||
for (const page of pages) {
|
const rad = (options.angle * Math.PI) / 180;
|
||||||
|
const halfW = imgWidth / 2;
|
||||||
|
const halfH = imgHeight / 2;
|
||||||
|
|
||||||
|
const targetIndices = options.pageIndices ?? pages.map((_, i) => i);
|
||||||
|
for (const idx of targetIndices) {
|
||||||
|
const page = pages[idx];
|
||||||
|
if (!page) continue;
|
||||||
const { width, height } = page.getSize();
|
const { width, height } = page.getSize();
|
||||||
const textWidth = font.widthOfTextAtSize(options.text, options.fontSize);
|
const cx = posX * width;
|
||||||
|
const cy = posY * height;
|
||||||
|
|
||||||
page.drawText(options.text, {
|
page.drawImage(image, {
|
||||||
x: (width - textWidth) / 2,
|
x: cx - Math.cos(rad) * halfW + Math.sin(rad) * halfH,
|
||||||
y: height / 2,
|
y: cy - Math.sin(rad) * halfW - Math.cos(rad) * halfH,
|
||||||
font,
|
width: imgWidth,
|
||||||
size: options.fontSize,
|
height: imgHeight,
|
||||||
color: rgb(options.color.r, options.color.g, options.color.b),
|
|
||||||
opacity: options.opacity,
|
opacity: options.opacity,
|
||||||
rotate: degrees(options.angle),
|
rotate: degrees(options.angle),
|
||||||
});
|
});
|
||||||
@@ -221,6 +265,9 @@ export interface ImageWatermarkOptions {
|
|||||||
opacity: number;
|
opacity: number;
|
||||||
angle: number;
|
angle: number;
|
||||||
scale: number;
|
scale: number;
|
||||||
|
x?: number;
|
||||||
|
y?: number;
|
||||||
|
pageIndices?: number[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function addImageWatermark(
|
export async function addImageWatermark(
|
||||||
@@ -233,15 +280,26 @@ export async function addImageWatermark(
|
|||||||
? await pdfDoc.embedPng(options.imageBytes)
|
? await pdfDoc.embedPng(options.imageBytes)
|
||||||
: await pdfDoc.embedJpg(options.imageBytes);
|
: await pdfDoc.embedJpg(options.imageBytes);
|
||||||
const pages = pdfDoc.getPages();
|
const pages = pdfDoc.getPages();
|
||||||
|
const posX = options.x ?? 0.5;
|
||||||
|
const posY = options.y ?? 0.5;
|
||||||
|
|
||||||
for (const page of pages) {
|
const imgWidth = image.width * options.scale;
|
||||||
|
const imgHeight = image.height * options.scale;
|
||||||
|
const rad = (options.angle * Math.PI) / 180;
|
||||||
|
const halfW = imgWidth / 2;
|
||||||
|
const halfH = imgHeight / 2;
|
||||||
|
|
||||||
|
const targetIndices = options.pageIndices ?? pages.map((_, i) => i);
|
||||||
|
for (const idx of targetIndices) {
|
||||||
|
const page = pages[idx];
|
||||||
|
if (!page) continue;
|
||||||
const { width, height } = page.getSize();
|
const { width, height } = page.getSize();
|
||||||
const imgWidth = image.width * options.scale;
|
const cx = posX * width;
|
||||||
const imgHeight = image.height * options.scale;
|
const cy = posY * height;
|
||||||
|
|
||||||
page.drawImage(image, {
|
page.drawImage(image, {
|
||||||
x: (width - imgWidth) / 2,
|
x: cx - Math.cos(rad) * halfW + Math.sin(rad) * halfH,
|
||||||
y: (height - imgHeight) / 2,
|
y: cy - Math.sin(rad) * halfW - Math.cos(rad) * halfH,
|
||||||
width: imgWidth,
|
width: imgWidth,
|
||||||
height: imgHeight,
|
height: imgHeight,
|
||||||
opacity: options.opacity,
|
opacity: options.opacity,
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ export interface RenderConfig {
|
|||||||
*/
|
*/
|
||||||
interface PageTask {
|
interface PageTask {
|
||||||
pageNumber: number;
|
pageNumber: number;
|
||||||
pdfjsDoc: any;
|
pdfjsDoc: pdfjsLib.PDFDocumentProxy;
|
||||||
fileName?: string;
|
fileName?: string;
|
||||||
container: HTMLElement;
|
container: HTMLElement;
|
||||||
scale?: number;
|
scale?: number;
|
||||||
@@ -92,9 +92,9 @@ export function createPlaceholder(
|
|||||||
* Renders a single page to canvas
|
* Renders a single page to canvas
|
||||||
*/
|
*/
|
||||||
export async function renderPageToCanvas(
|
export async function renderPageToCanvas(
|
||||||
pdfjsDoc: any,
|
pdfjsDoc: pdfjsLib.PDFDocumentProxy,
|
||||||
pageNumber: number,
|
pageNumber: number,
|
||||||
scale: number = 0.5
|
scale: number = 1
|
||||||
): Promise<HTMLCanvasElement> {
|
): Promise<HTMLCanvasElement> {
|
||||||
const page = await pdfjsDoc.getPage(pageNumber);
|
const page = await pdfjsDoc.getPage(pageNumber);
|
||||||
const viewport = page.getViewport({ scale });
|
const viewport = page.getViewport({ scale });
|
||||||
@@ -103,7 +103,10 @@ export async function renderPageToCanvas(
|
|||||||
canvas.height = viewport.height;
|
canvas.height = viewport.height;
|
||||||
canvas.width = viewport.width;
|
canvas.width = viewport.width;
|
||||||
|
|
||||||
const context = canvas.getContext('2d')!;
|
const context = canvas.getContext('2d');
|
||||||
|
if (!context) {
|
||||||
|
throw new Error(`Failed to get 2D context for page ${pageNumber}`);
|
||||||
|
}
|
||||||
|
|
||||||
await page.render({
|
await page.render({
|
||||||
canvasContext: context,
|
canvasContext: context,
|
||||||
@@ -115,12 +118,9 @@ export async function renderPageToCanvas(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Renders a batch of pages in parallel
|
* Renders a batch of pages
|
||||||
*/
|
*/
|
||||||
async function renderPageBatch(
|
async function renderPageBatch(tasks: PageTask[]): Promise<void> {
|
||||||
tasks: PageTask[],
|
|
||||||
onProgress?: (current: number, total: number) => void
|
|
||||||
): Promise<void> {
|
|
||||||
for (const task of tasks) {
|
for (const task of tasks) {
|
||||||
try {
|
try {
|
||||||
const canvas = await renderPageToCanvas(
|
const canvas = await renderPageToCanvas(
|
||||||
@@ -147,6 +147,13 @@ async function renderPageBatch(
|
|||||||
parent.insertBefore(wrapper, placeholder);
|
parent.insertBefore(wrapper, placeholder);
|
||||||
parent.removeChild(placeholder);
|
parent.removeChild(placeholder);
|
||||||
} else {
|
} else {
|
||||||
|
const existingRendered = task.container.querySelector(
|
||||||
|
`[data-page-number="${task.pageNumber}"]:not([data-lazy-load="true"])`
|
||||||
|
);
|
||||||
|
if (existingRendered) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
const allChildren = Array.from(
|
const allChildren = Array.from(
|
||||||
task.container.children
|
task.container.children
|
||||||
) as HTMLElement[];
|
) as HTMLElement[];
|
||||||
@@ -207,7 +214,7 @@ function setupLazyRendering(
|
|||||||
task.placeholderElement = placeholder;
|
task.placeholderElement = placeholder;
|
||||||
|
|
||||||
// Render this page immediately (not waiting for isRendering flag)
|
// Render this page immediately (not waiting for isRendering flag)
|
||||||
renderPageBatch([task], config.onProgress)
|
renderPageBatch([task])
|
||||||
.then(() => {
|
.then(() => {
|
||||||
// Trigger callback after lazy load batch
|
// Trigger callback after lazy load batch
|
||||||
if (config.onBatchComplete) {
|
if (config.onBatchComplete) {
|
||||||
@@ -253,7 +260,7 @@ function requestIdleCallbackPolyfill(callback: () => void): void {
|
|||||||
* Main function to render pages progressively with optional lazy loading
|
* Main function to render pages progressively with optional lazy loading
|
||||||
*/
|
*/
|
||||||
export async function renderPagesProgressively(
|
export async function renderPagesProgressively(
|
||||||
pdfjsDoc: any,
|
pdfjsDoc: pdfjsLib.PDFDocumentProxy,
|
||||||
container: HTMLElement,
|
container: HTMLElement,
|
||||||
createWrapper: (
|
createWrapper: (
|
||||||
canvas: HTMLCanvasElement,
|
canvas: HTMLCanvasElement,
|
||||||
@@ -263,21 +270,19 @@ export async function renderPagesProgressively(
|
|||||||
config: RenderConfig = {}
|
config: RenderConfig = {}
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const {
|
const {
|
||||||
batchSize = 8, // Increased from 5 to 8 for faster initial render
|
batchSize = 8,
|
||||||
useLazyLoading = true,
|
useLazyLoading = true,
|
||||||
eagerLoadBatches = 2, // Eagerly load 1 batch ahead by default
|
eagerLoadBatches = 2,
|
||||||
onProgress,
|
onProgress,
|
||||||
onBatchComplete,
|
onBatchComplete,
|
||||||
} = config;
|
} = config;
|
||||||
|
|
||||||
const totalPages = pdfjsDoc.numPages;
|
const totalPages = pdfjsDoc.numPages;
|
||||||
|
|
||||||
// Render more pages initially to reduce lazy loading issues
|
|
||||||
const initialRenderCount = useLazyLoading
|
const initialRenderCount = useLazyLoading
|
||||||
? Math.min(20, totalPages) // Increased from 12 to 20 pages
|
? Math.min(20, totalPages)
|
||||||
: totalPages;
|
: totalPages;
|
||||||
|
|
||||||
// CRITICAL FIX: Create placeholders for ALL pages first to maintain order
|
|
||||||
const placeholders: HTMLElement[] = [];
|
const placeholders: HTMLElement[] = [];
|
||||||
for (let i = 1; i <= totalPages; i++) {
|
for (let i = 1; i <= totalPages; i++) {
|
||||||
const placeholder = createPlaceholder(i);
|
const placeholder = createPlaceholder(i);
|
||||||
@@ -293,7 +298,7 @@ export async function renderPagesProgressively(
|
|||||||
pageNumber: i,
|
pageNumber: i,
|
||||||
pdfjsDoc,
|
pdfjsDoc,
|
||||||
container,
|
container,
|
||||||
scale: config.useLazyLoading ? 0.3 : 0.5,
|
scale: useLazyLoading ? 0.5 : 1,
|
||||||
createWrapper,
|
createWrapper,
|
||||||
placeholderElement: placeholders[i - 1],
|
placeholderElement: placeholders[i - 1],
|
||||||
});
|
});
|
||||||
@@ -330,19 +335,24 @@ export async function renderPagesProgressively(
|
|||||||
|
|
||||||
const batch = initialTasks.slice(i, i + batchSize);
|
const batch = initialTasks.slice(i, i + batchSize);
|
||||||
|
|
||||||
await new Promise<void>((resolve) => {
|
await new Promise<void>((resolve, reject) => {
|
||||||
requestIdleCallbackPolyfill(async () => {
|
requestIdleCallbackPolyfill(() => {
|
||||||
await renderPageBatch(batch, onProgress);
|
renderPageBatch(batch)
|
||||||
|
.then(() => {
|
||||||
|
if (onProgress) {
|
||||||
|
onProgress(
|
||||||
|
Math.min(i + batchSize, initialRenderCount),
|
||||||
|
totalPages
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
if (onProgress) {
|
if (onBatchComplete) {
|
||||||
onProgress(Math.min(i + batchSize, initialRenderCount), totalPages);
|
onBatchComplete();
|
||||||
}
|
}
|
||||||
|
|
||||||
if (onBatchComplete) {
|
resolve();
|
||||||
onBatchComplete();
|
})
|
||||||
}
|
.catch(reject);
|
||||||
|
|
||||||
resolve();
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -397,8 +407,11 @@ function renderEagerBatch(config: RenderConfig): void {
|
|||||||
requestIdleCallbackPolyfill(async () => {
|
requestIdleCallbackPolyfill(async () => {
|
||||||
if (config.shouldCancel?.()) return;
|
if (config.shouldCancel?.()) return;
|
||||||
|
|
||||||
// Remove these tasks from pending since we're rendering them eagerly
|
const tasksToRender = batch.filter((task) =>
|
||||||
batch.forEach((task) => {
|
lazyLoadState.pendingTasksByPageNumber.has(task.pageNumber)
|
||||||
|
);
|
||||||
|
|
||||||
|
tasksToRender.forEach((task) => {
|
||||||
const placeholder = task.placeholderElement;
|
const placeholder = task.placeholderElement;
|
||||||
if (placeholder && lazyLoadState.observer) {
|
if (placeholder && lazyLoadState.observer) {
|
||||||
lazyLoadState.observer.unobserve(placeholder);
|
lazyLoadState.observer.unobserve(placeholder);
|
||||||
@@ -407,7 +420,18 @@ function renderEagerBatch(config: RenderConfig): void {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
await renderPageBatch(batch, config.onProgress);
|
if (tasksToRender.length === 0) {
|
||||||
|
lazyLoadState.nextEagerIndex = batchEnd;
|
||||||
|
const remainingBatches = Math.ceil(
|
||||||
|
(eagerLoadQueue.length - batchEnd) / batchSize
|
||||||
|
);
|
||||||
|
if (remainingBatches > 0 && remainingBatches < eagerLoadBatches) {
|
||||||
|
renderEagerBatch(config);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await renderPageBatch(tasksToRender);
|
||||||
|
|
||||||
if (config.onBatchComplete) {
|
if (config.onBatchComplete) {
|
||||||
config.onBatchComplete();
|
config.onBatchComplete();
|
||||||
@@ -421,7 +445,6 @@ function renderEagerBatch(config: RenderConfig): void {
|
|||||||
(eagerLoadQueue.length - batchEnd) / batchSize
|
(eagerLoadQueue.length - batchEnd) / batchSize
|
||||||
);
|
);
|
||||||
if (remainingBatches > 0 && remainingBatches < eagerLoadBatches) {
|
if (remainingBatches > 0 && remainingBatches < eagerLoadBatches) {
|
||||||
// Continue eager loading if we have more batches within the eager threshold
|
|
||||||
renderEagerBatch(config);
|
renderEagerBatch(config);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ interface WasmProviderConfig {
|
|||||||
const STORAGE_KEY = 'bentopdf:wasm-providers';
|
const STORAGE_KEY = 'bentopdf:wasm-providers';
|
||||||
|
|
||||||
const CDN_DEFAULTS: Record<WasmPackage, string> = {
|
const CDN_DEFAULTS: Record<WasmPackage, string> = {
|
||||||
pymupdf: 'https://cdn.jsdelivr.net/npm/@bentopdf/[email protected]4/',
|
pymupdf: 'https://cdn.jsdelivr.net/npm/@bentopdf/[email protected]6/',
|
||||||
ghostscript: 'https://cdn.jsdelivr.net/npm/@bentopdf/gs-wasm/assets/',
|
ghostscript: 'https://cdn.jsdelivr.net/npm/@bentopdf/gs-wasm/assets/',
|
||||||
cpdf: 'https://cdn.jsdelivr.net/npm/coherentpdf/dist/',
|
cpdf: 'https://cdn.jsdelivr.net/npm/coherentpdf/dist/',
|
||||||
};
|
};
|
||||||
|
|||||||
+174
-149
@@ -8,7 +8,7 @@ import { LitPlugin, Presets as LitPresets } from '@retejs/lit-plugin';
|
|||||||
import type { ClassicScheme, LitArea2D } from '@retejs/lit-plugin';
|
import type { ClassicScheme, LitArea2D } from '@retejs/lit-plugin';
|
||||||
import { DataflowEngine } from 'rete-engine';
|
import { DataflowEngine } from 'rete-engine';
|
||||||
import type { DataflowEngineScheme } from 'rete-engine';
|
import type { DataflowEngineScheme } from 'rete-engine';
|
||||||
import { html } from 'lit';
|
import { LitElement, html } from 'lit';
|
||||||
import type { BaseWorkflowNode } from './nodes/base-node';
|
import type { BaseWorkflowNode } from './nodes/base-node';
|
||||||
// @ts-ignore -- Vite ?inline import for injecting into Shadow DOM
|
// @ts-ignore -- Vite ?inline import for injecting into Shadow DOM
|
||||||
import phosphorCSS from '@phosphor-icons/web/regular?inline';
|
import phosphorCSS from '@phosphor-icons/web/regular?inline';
|
||||||
@@ -49,6 +49,175 @@ function getStatusInfo(status: string, connected: boolean) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
class WorkflowNodeElement extends LitElement {
|
||||||
|
static properties = {
|
||||||
|
data: { attribute: false },
|
||||||
|
emit: { attribute: false },
|
||||||
|
};
|
||||||
|
|
||||||
|
declare data: BaseWorkflowNode | undefined;
|
||||||
|
declare emit: ((data: unknown) => void) | undefined;
|
||||||
|
|
||||||
|
createRenderRoot(): HTMLElement | ShadowRoot {
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
render() {
|
||||||
|
if (!this.data) return html``;
|
||||||
|
const node = this.data;
|
||||||
|
const inputs = Object.entries(node.inputs || {});
|
||||||
|
const outputs = Object.entries(node.outputs || {});
|
||||||
|
const color = categoryColors[node.category] || '#6b7280';
|
||||||
|
const emitFn = this.emit;
|
||||||
|
|
||||||
|
return html`
|
||||||
|
<div
|
||||||
|
style="
|
||||||
|
position: relative; display: flex; flex-direction: column;
|
||||||
|
align-items: center; width: 280px;
|
||||||
|
"
|
||||||
|
>
|
||||||
|
${inputs.length > 0
|
||||||
|
? html`
|
||||||
|
<div
|
||||||
|
style="display: flex; justify-content: center; gap: 8px; position: relative; z-index: 1; margin-bottom: -7px;"
|
||||||
|
>
|
||||||
|
${inputs.map(([key, input]) =>
|
||||||
|
input
|
||||||
|
? html`
|
||||||
|
<div
|
||||||
|
style="display: flex; align-items: center; justify-content: center;"
|
||||||
|
>
|
||||||
|
<rete-ref
|
||||||
|
.data=${{
|
||||||
|
type: 'socket',
|
||||||
|
side: 'input',
|
||||||
|
key,
|
||||||
|
nodeId: node.id,
|
||||||
|
payload: input.socket,
|
||||||
|
}}
|
||||||
|
.emit=${emitFn}
|
||||||
|
></rete-ref>
|
||||||
|
</div>
|
||||||
|
`
|
||||||
|
: null
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
`
|
||||||
|
: null}
|
||||||
|
<div
|
||||||
|
style="
|
||||||
|
background: #1f2937; border: 1px solid #374151;
|
||||||
|
border-radius: 12px; width: 100%; overflow: hidden;
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style="height: 3px; border-radius: 10px 10px 0 0; overflow: hidden;"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
data-wf="bar"
|
||||||
|
style="
|
||||||
|
height: 100%; width: 100%;
|
||||||
|
background: #6b7280; opacity: 0.25;
|
||||||
|
"
|
||||||
|
></div>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
style="padding: 6px 14px; display: flex; align-items: center; gap: 6px;"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
data-wf="dot"
|
||||||
|
style="
|
||||||
|
width: 7px; height: 7px; border-radius: 50%; background: #6b7280; flex-shrink: 0;
|
||||||
|
"
|
||||||
|
></span>
|
||||||
|
<span
|
||||||
|
data-wf="label"
|
||||||
|
style="font-size: 10px; color: #6b7280; font-weight: 500; flex: 1;"
|
||||||
|
>Not connected</span
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
data-wf-delete="${node.id}"
|
||||||
|
style="
|
||||||
|
cursor: pointer; display: flex; align-items: center; justify-content: center;
|
||||||
|
width: 18px; height: 18px; border-radius: 4px;
|
||||||
|
color: #6b7280; transition: all 0.15s;
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
width="12"
|
||||||
|
height="12"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
>
|
||||||
|
<line x1="18" y1="6" x2="6" y2="18"></line>
|
||||||
|
<line x1="6" y1="6" x2="18" y2="18"></line>
|
||||||
|
</svg>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div style="height: 1px; background: #374151; margin: 0 14px;"></div>
|
||||||
|
<div
|
||||||
|
style="padding: 10px 14px 12px; display: flex; align-items: flex-start; gap: 10px;"
|
||||||
|
>
|
||||||
|
<i
|
||||||
|
class="ph ${node.icon}"
|
||||||
|
style="font-size: 18px; color: ${color}; flex-shrink: 0; margin-top: 1px; line-height: 1;"
|
||||||
|
></i>
|
||||||
|
<div style="flex: 1; min-width: 0;">
|
||||||
|
<div
|
||||||
|
style="font-size: 13px; font-weight: 600; color: #f3f4f6; line-height: 1.3;"
|
||||||
|
>
|
||||||
|
${node.label}
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
style="font-size: 11px; color: #9ca3af; margin-top: 2px; line-height: 1.3;"
|
||||||
|
>
|
||||||
|
${node.description}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
${outputs.length > 0
|
||||||
|
? html`
|
||||||
|
<div
|
||||||
|
style="display: flex; justify-content: center; gap: 8px; position: relative; z-index: 1; margin-top: -7px;"
|
||||||
|
>
|
||||||
|
${outputs.map(([key, output]) =>
|
||||||
|
output
|
||||||
|
? html`
|
||||||
|
<div
|
||||||
|
style="display: flex; align-items: center; justify-content: center;"
|
||||||
|
>
|
||||||
|
<rete-ref
|
||||||
|
.data=${{
|
||||||
|
type: 'socket',
|
||||||
|
side: 'output',
|
||||||
|
key,
|
||||||
|
nodeId: node.id,
|
||||||
|
payload: output.socket,
|
||||||
|
}}
|
||||||
|
.emit=${emitFn}
|
||||||
|
></rete-ref>
|
||||||
|
</div>
|
||||||
|
`
|
||||||
|
: null
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
`
|
||||||
|
: null}
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!customElements.get('wf-node')) {
|
||||||
|
customElements.define('wf-node', WorkflowNodeElement);
|
||||||
|
}
|
||||||
|
|
||||||
export function updateNodeDisplay(
|
export function updateNodeDisplay(
|
||||||
nodeId: string,
|
nodeId: string,
|
||||||
editor: NodeEditor<ClassicScheme>,
|
editor: NodeEditor<ClassicScheme>,
|
||||||
@@ -111,154 +280,10 @@ export async function createWorkflowEditor(
|
|||||||
customize: {
|
customize: {
|
||||||
node(data) {
|
node(data) {
|
||||||
return ({ emit }: { emit: (data: unknown) => void }) => {
|
return ({ emit }: { emit: (data: unknown) => void }) => {
|
||||||
const node = data.payload as BaseWorkflowNode;
|
return html`<wf-node
|
||||||
const inputs = Object.entries(node.inputs || {});
|
.data=${data.payload}
|
||||||
const outputs = Object.entries(node.outputs || {});
|
.emit=${emit}
|
||||||
const color = categoryColors[node.category] || '#6b7280';
|
></wf-node>`;
|
||||||
|
|
||||||
return html`
|
|
||||||
<div
|
|
||||||
style="
|
|
||||||
position: relative; display: flex; flex-direction: column;
|
|
||||||
align-items: center; width: 280px;
|
|
||||||
"
|
|
||||||
>
|
|
||||||
${inputs.length > 0
|
|
||||||
? html`
|
|
||||||
<div
|
|
||||||
style="display: flex; justify-content: center; gap: 8px; position: relative; z-index: 1; margin-bottom: -7px;"
|
|
||||||
>
|
|
||||||
${inputs.map(([key, input]) =>
|
|
||||||
input
|
|
||||||
? html`
|
|
||||||
<div
|
|
||||||
style="display: flex; align-items: center; justify-content: center;"
|
|
||||||
>
|
|
||||||
<rete-ref
|
|
||||||
.data=${{
|
|
||||||
type: 'socket',
|
|
||||||
side: 'input',
|
|
||||||
key,
|
|
||||||
nodeId: node.id,
|
|
||||||
payload: input.socket,
|
|
||||||
}}
|
|
||||||
.emit=${emit}
|
|
||||||
></rete-ref>
|
|
||||||
</div>
|
|
||||||
`
|
|
||||||
: null
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
`
|
|
||||||
: null}
|
|
||||||
<div
|
|
||||||
style="
|
|
||||||
background: #1f2937; border: 1px solid #374151;
|
|
||||||
border-radius: 12px; width: 100%; overflow: hidden;
|
|
||||||
"
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
style="height: 3px; border-radius: 10px 10px 0 0; overflow: hidden;"
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
data-wf="bar"
|
|
||||||
style="
|
|
||||||
height: 100%; width: 100%;
|
|
||||||
background: #6b7280; opacity: 0.25;
|
|
||||||
"
|
|
||||||
></div>
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
style="padding: 6px 14px; display: flex; align-items: center; gap: 6px;"
|
|
||||||
>
|
|
||||||
<span
|
|
||||||
data-wf="dot"
|
|
||||||
style="
|
|
||||||
width: 7px; height: 7px; border-radius: 50%; background: #6b7280; flex-shrink: 0;
|
|
||||||
"
|
|
||||||
></span>
|
|
||||||
<span
|
|
||||||
data-wf="label"
|
|
||||||
style="font-size: 10px; color: #6b7280; font-weight: 500; flex: 1;"
|
|
||||||
>Not connected</span
|
|
||||||
>
|
|
||||||
<span
|
|
||||||
data-wf-delete="${node.id}"
|
|
||||||
style="
|
|
||||||
cursor: pointer; display: flex; align-items: center; justify-content: center;
|
|
||||||
width: 18px; height: 18px; border-radius: 4px;
|
|
||||||
color: #6b7280; transition: all 0.15s;
|
|
||||||
"
|
|
||||||
>
|
|
||||||
<svg
|
|
||||||
width="12"
|
|
||||||
height="12"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
stroke-width="2"
|
|
||||||
stroke-linecap="round"
|
|
||||||
stroke-linejoin="round"
|
|
||||||
>
|
|
||||||
<line x1="18" y1="6" x2="6" y2="18"></line>
|
|
||||||
<line x1="6" y1="6" x2="18" y2="18"></line>
|
|
||||||
</svg>
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
style="height: 1px; background: #374151; margin: 0 14px;"
|
|
||||||
></div>
|
|
||||||
<div
|
|
||||||
style="padding: 10px 14px 12px; display: flex; align-items: flex-start; gap: 10px;"
|
|
||||||
>
|
|
||||||
<i
|
|
||||||
class="ph ${node.icon}"
|
|
||||||
style="font-size: 18px; color: ${color}; flex-shrink: 0; margin-top: 1px; line-height: 1;"
|
|
||||||
></i>
|
|
||||||
<div style="flex: 1; min-width: 0;">
|
|
||||||
<div
|
|
||||||
style="font-size: 13px; font-weight: 600; color: #f3f4f6; line-height: 1.3;"
|
|
||||||
>
|
|
||||||
${node.label}
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
style="font-size: 11px; color: #9ca3af; margin-top: 2px; line-height: 1.3;"
|
|
||||||
>
|
|
||||||
${node.description}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
${outputs.length > 0
|
|
||||||
? html`
|
|
||||||
<div
|
|
||||||
style="display: flex; justify-content: center; gap: 8px; position: relative; z-index: 1; margin-top: -7px;"
|
|
||||||
>
|
|
||||||
${outputs.map(([key, output]) =>
|
|
||||||
output
|
|
||||||
? html`
|
|
||||||
<div
|
|
||||||
style="display: flex; align-items: center; justify-content: center;"
|
|
||||||
>
|
|
||||||
<rete-ref
|
|
||||||
.data=${{
|
|
||||||
type: 'socket',
|
|
||||||
side: 'output',
|
|
||||||
key,
|
|
||||||
nodeId: node.id,
|
|
||||||
payload: output.socket,
|
|
||||||
}}
|
|
||||||
.emit=${emit}
|
|
||||||
></rete-ref>
|
|
||||||
</div>
|
|
||||||
`
|
|
||||||
: null
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
`
|
|
||||||
: null}
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
socket() {
|
socket() {
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ export abstract class BaseWorkflowNode extends ClassicPreset.Node {
|
|||||||
width = 280;
|
width = 280;
|
||||||
height = 140;
|
height = 140;
|
||||||
execStatus: 'idle' | 'running' | 'completed' | 'error' = 'idle';
|
execStatus: 'idle' | 'running' | 'completed' | 'error' = 'idle';
|
||||||
|
nodeType: string = '';
|
||||||
|
|
||||||
constructor(label: string) {
|
constructor(label: string) {
|
||||||
super(label);
|
super(label);
|
||||||
|
|||||||
@@ -593,7 +593,9 @@ export const nodeRegistry: Record<string, NodeRegistryEntry> = {
|
|||||||
export function createNodeByType(type: string): BaseWorkflowNode | null {
|
export function createNodeByType(type: string): BaseWorkflowNode | null {
|
||||||
const entry = nodeRegistry[type];
|
const entry = nodeRegistry[type];
|
||||||
if (!entry) return null;
|
if (!entry) return null;
|
||||||
return entry.factory();
|
const node = entry.factory();
|
||||||
|
node.nodeType = type;
|
||||||
|
return node;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getNodesByCategory(): Record<
|
export function getNodesByCategory(): Record<
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ export class RemoveBlankPagesNode extends BaseWorkflowNode {
|
|||||||
|
|
||||||
private async isPageBlank(
|
private async isPageBlank(
|
||||||
page: pdfjsLib.PDFPageProxy,
|
page: pdfjsLib.PDFPageProxy,
|
||||||
threshold: number
|
maxNonWhitePercent: number
|
||||||
): Promise<boolean> {
|
): Promise<boolean> {
|
||||||
const viewport = page.getViewport({ scale: 0.5 });
|
const viewport = page.getViewport({ scale: 0.5 });
|
||||||
const canvas = document.createElement('canvas');
|
const canvas = document.createElement('canvas');
|
||||||
@@ -34,12 +34,14 @@ export class RemoveBlankPagesNode extends BaseWorkflowNode {
|
|||||||
|
|
||||||
const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
|
const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
|
||||||
const data = imageData.data;
|
const data = imageData.data;
|
||||||
let totalBrightness = 0;
|
const totalPixels = data.length / 4;
|
||||||
|
let nonWhitePixels = 0;
|
||||||
for (let i = 0; i < data.length; i += 4) {
|
for (let i = 0; i < data.length; i += 4) {
|
||||||
totalBrightness += (data[i] + data[i + 1] + data[i + 2]) / 3;
|
const brightness = (data[i] + data[i + 1] + data[i + 2]) / 3;
|
||||||
|
if (brightness < 240) nonWhitePixels++;
|
||||||
}
|
}
|
||||||
const avgBrightness = totalBrightness / (data.length / 4);
|
const nonWhitePercent = (nonWhitePixels / totalPixels) * 100;
|
||||||
return avgBrightness > threshold;
|
return nonWhitePercent <= maxNonWhitePercent;
|
||||||
}
|
}
|
||||||
|
|
||||||
async data(
|
async data(
|
||||||
@@ -50,7 +52,10 @@ export class RemoveBlankPagesNode extends BaseWorkflowNode {
|
|||||||
const threshCtrl = this.controls['threshold'] as
|
const threshCtrl = this.controls['threshold'] as
|
||||||
| ClassicPreset.InputControl<'number'>
|
| ClassicPreset.InputControl<'number'>
|
||||||
| undefined;
|
| undefined;
|
||||||
const threshold = Math.max(200, Math.min(255, threshCtrl?.value ?? 250));
|
const maxNonWhitePercent = Math.max(
|
||||||
|
0.1,
|
||||||
|
Math.min(5, threshCtrl?.value ?? 0.5)
|
||||||
|
);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
pdf: await processBatch(pdfInputs, async (input) => {
|
pdf: await processBatch(pdfInputs, async (input) => {
|
||||||
@@ -61,7 +66,7 @@ export class RemoveBlankPagesNode extends BaseWorkflowNode {
|
|||||||
|
|
||||||
for (let i = 1; i <= pdfjsDoc.numPages; i++) {
|
for (let i = 1; i <= pdfjsDoc.numPages; i++) {
|
||||||
const page = await pdfjsDoc.getPage(i);
|
const page = await pdfjsDoc.getPage(i);
|
||||||
const blank = await this.isPageBlank(page, threshold);
|
const blank = await this.isPageBlank(page, maxNonWhitePercent);
|
||||||
if (!blank) {
|
if (!blank) {
|
||||||
nonBlankIndices.push(i - 1);
|
nonBlankIndices.push(i - 1);
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -3,9 +3,10 @@ import { BaseWorkflowNode } from './base-node';
|
|||||||
import { pdfSocket } from '../sockets';
|
import { pdfSocket } from '../sockets';
|
||||||
import type { SocketData } from '../types';
|
import type { SocketData } from '../types';
|
||||||
import { requirePdfInput, processBatch } from '../types';
|
import { requirePdfInput, processBatch } from '../types';
|
||||||
import { addTextWatermark } from '../../utils/pdf-operations';
|
import { addTextWatermark, parsePageRange } from '../../utils/pdf-operations';
|
||||||
import { PDFDocument } from 'pdf-lib';
|
import { PDFDocument } from 'pdf-lib';
|
||||||
import { hexToRgb } from '../../utils/helpers.js';
|
import { hexToRgb } from '../../utils/helpers.js';
|
||||||
|
import * as pdfjsLib from 'pdfjs-dist';
|
||||||
|
|
||||||
export class WatermarkNode extends BaseWorkflowNode {
|
export class WatermarkNode extends BaseWorkflowNode {
|
||||||
readonly category = 'Edit & Annotate' as const;
|
readonly category = 'Edit & Annotate' as const;
|
||||||
@@ -39,6 +40,18 @@ export class WatermarkNode extends BaseWorkflowNode {
|
|||||||
'angle',
|
'angle',
|
||||||
new ClassicPreset.InputControl('number', { initial: -45 })
|
new ClassicPreset.InputControl('number', { initial: -45 })
|
||||||
);
|
);
|
||||||
|
this.addControl(
|
||||||
|
'position',
|
||||||
|
new ClassicPreset.InputControl('text', { initial: 'center' })
|
||||||
|
);
|
||||||
|
this.addControl(
|
||||||
|
'pages',
|
||||||
|
new ClassicPreset.InputControl('text', { initial: 'all' })
|
||||||
|
);
|
||||||
|
this.addControl(
|
||||||
|
'flatten',
|
||||||
|
new ClassicPreset.InputControl('text', { initial: 'no' })
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
async data(
|
async data(
|
||||||
@@ -67,16 +80,89 @@ export class WatermarkNode extends BaseWorkflowNode {
|
|||||||
const opacity = getNum('opacity', 30) / 100;
|
const opacity = getNum('opacity', 30) / 100;
|
||||||
const angle = getNum('angle', -45);
|
const angle = getNum('angle', -45);
|
||||||
|
|
||||||
|
const positionPresets: Record<string, { x: number; y: number }> = {
|
||||||
|
'top-left': { x: 0.15, y: 0.15 },
|
||||||
|
top: { x: 0.5, y: 0.15 },
|
||||||
|
'top-right': { x: 0.85, y: 0.15 },
|
||||||
|
left: { x: 0.15, y: 0.5 },
|
||||||
|
center: { x: 0.5, y: 0.5 },
|
||||||
|
right: { x: 0.85, y: 0.5 },
|
||||||
|
'bottom-left': { x: 0.15, y: 0.85 },
|
||||||
|
bottom: { x: 0.5, y: 0.85 },
|
||||||
|
'bottom-right': { x: 0.85, y: 0.85 },
|
||||||
|
};
|
||||||
|
const posKey = getText('position', 'center').trim().toLowerCase();
|
||||||
|
const { x, y } = positionPresets[posKey] ?? positionPresets['center'];
|
||||||
|
|
||||||
|
const pagesStr = getText('pages', 'all').trim().toLowerCase();
|
||||||
|
const shouldFlatten =
|
||||||
|
getText('flatten', 'no').trim().toLowerCase() === 'yes';
|
||||||
|
|
||||||
return {
|
return {
|
||||||
pdf: await processBatch(pdfInputs, async (input) => {
|
pdf: await processBatch(pdfInputs, async (input) => {
|
||||||
const resultBytes = await addTextWatermark(input.bytes, {
|
const srcDoc = await PDFDocument.load(input.bytes);
|
||||||
|
const totalPages = srcDoc.getPageCount();
|
||||||
|
|
||||||
|
const pageIndices =
|
||||||
|
pagesStr === 'all' ? undefined : parsePageRange(pagesStr, totalPages);
|
||||||
|
|
||||||
|
let resultBytes = await addTextWatermark(input.bytes, {
|
||||||
text: watermarkText,
|
text: watermarkText,
|
||||||
fontSize,
|
fontSize,
|
||||||
color: { r: c.r, g: c.g, b: c.b },
|
color: { r: c.r, g: c.g, b: c.b },
|
||||||
opacity,
|
opacity,
|
||||||
angle,
|
angle,
|
||||||
|
x,
|
||||||
|
y: 1 - y,
|
||||||
|
pageIndices,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (shouldFlatten) {
|
||||||
|
const watermarkedPdf = await pdfjsLib.getDocument({
|
||||||
|
data: resultBytes.slice(),
|
||||||
|
}).promise;
|
||||||
|
const flattenedDoc = await PDFDocument.create();
|
||||||
|
const renderScale = 2.5;
|
||||||
|
|
||||||
|
for (let i = 1; i <= watermarkedPdf.numPages; i++) {
|
||||||
|
const page = await watermarkedPdf.getPage(i);
|
||||||
|
const unscaledVP = page.getViewport({ scale: 1 });
|
||||||
|
const viewport = page.getViewport({ scale: renderScale });
|
||||||
|
|
||||||
|
const canvas = document.createElement('canvas');
|
||||||
|
canvas.width = viewport.width;
|
||||||
|
canvas.height = viewport.height;
|
||||||
|
const ctx = canvas.getContext('2d')!;
|
||||||
|
await page.render({ canvasContext: ctx, canvas, viewport }).promise;
|
||||||
|
|
||||||
|
const jpegBytes = await new Promise<ArrayBuffer>(
|
||||||
|
(resolve, reject) =>
|
||||||
|
canvas.toBlob(
|
||||||
|
(blob) =>
|
||||||
|
blob
|
||||||
|
? blob.arrayBuffer().then(resolve)
|
||||||
|
: reject(new Error(`Failed to rasterize page ${i}`)),
|
||||||
|
'image/jpeg',
|
||||||
|
0.92
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
const image = await flattenedDoc.embedJpg(jpegBytes);
|
||||||
|
const newPage = flattenedDoc.addPage([
|
||||||
|
unscaledVP.width,
|
||||||
|
unscaledVP.height,
|
||||||
|
]);
|
||||||
|
newPage.drawImage(image, {
|
||||||
|
x: 0,
|
||||||
|
y: 0,
|
||||||
|
width: unscaledVP.width,
|
||||||
|
height: unscaledVP.height,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
resultBytes = new Uint8Array(await flattenedDoc.save());
|
||||||
|
}
|
||||||
|
|
||||||
const resultDoc = await PDFDocument.load(resultBytes);
|
const resultDoc = await PDFDocument.load(resultBytes);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -25,8 +25,7 @@ interface SerializedConnection {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function getNodeType(node: BaseWorkflowNode): string | null {
|
function getNodeType(node: BaseWorkflowNode): string | null {
|
||||||
const constructorName = node.constructor.name;
|
return node.nodeType || null;
|
||||||
return constructorName || null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function serializeWorkflow(
|
function serializeWorkflow(
|
||||||
@@ -79,6 +78,22 @@ async function deserializeWorkflow(
|
|||||||
editor: NodeEditor<ClassicScheme>,
|
editor: NodeEditor<ClassicScheme>,
|
||||||
area: AreaPlugin<ClassicScheme, AreaExtra>
|
area: AreaPlugin<ClassicScheme, AreaExtra>
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
|
if (
|
||||||
|
!data ||
|
||||||
|
!Array.isArray((data as any).nodes) ||
|
||||||
|
!Array.isArray((data as any).connections)
|
||||||
|
) {
|
||||||
|
throw new Error(
|
||||||
|
'Invalid workflow file: missing nodes or connections array.'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ((data as any).version !== WORKFLOW_VERSION) {
|
||||||
|
console.warn(
|
||||||
|
`Workflow version mismatch: expected ${WORKFLOW_VERSION}, got ${(data as any).version}. Attempting load anyway.`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
for (const conn of editor.getConnections()) {
|
for (const conn of editor.getConnections()) {
|
||||||
await editor.removeConnection(conn.id);
|
await editor.removeConnection(conn.id);
|
||||||
}
|
}
|
||||||
@@ -129,9 +144,6 @@ async function deserializeWorkflow(
|
|||||||
|
|
||||||
if (skippedTypes.length > 0) {
|
if (skippedTypes.length > 0) {
|
||||||
console.warn('Skipped unknown node types during load:', skippedTypes);
|
console.warn('Skipped unknown node types during load:', skippedTypes);
|
||||||
throw new Error(
|
|
||||||
`Some nodes could not be loaded: ${skippedTypes.join(', ')}. They may have been removed or renamed.`
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -215,14 +227,11 @@ export function exportWorkflow(
|
|||||||
const json = JSON.stringify(data, null, 2);
|
const json = JSON.stringify(data, null, 2);
|
||||||
const blob = new Blob([json], { type: 'application/json' });
|
const blob = new Blob([json], { type: 'application/json' });
|
||||||
const url = URL.createObjectURL(blob);
|
const url = URL.createObjectURL(blob);
|
||||||
try {
|
const a = document.createElement('a');
|
||||||
const a = document.createElement('a');
|
a.href = url;
|
||||||
a.href = url;
|
a.download = 'workflow.json';
|
||||||
a.download = 'workflow.json';
|
a.click();
|
||||||
a.click();
|
setTimeout(() => URL.revokeObjectURL(url), 1000);
|
||||||
} finally {
|
|
||||||
URL.revokeObjectURL(url);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function importWorkflow(
|
export async function importWorkflow(
|
||||||
@@ -233,13 +242,16 @@ export async function importWorkflow(
|
|||||||
const input = document.createElement('input');
|
const input = document.createElement('input');
|
||||||
input.type = 'file';
|
input.type = 'file';
|
||||||
input.accept = '.json';
|
input.accept = '.json';
|
||||||
|
|
||||||
|
let settled = false;
|
||||||
|
|
||||||
input.onchange = async () => {
|
input.onchange = async () => {
|
||||||
|
settled = true;
|
||||||
const file = input.files?.[0];
|
const file = input.files?.[0];
|
||||||
if (!file) {
|
if (!file) {
|
||||||
resolve();
|
resolve();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const text = await file.text();
|
const text = await file.text();
|
||||||
const data = JSON.parse(text) as SerializedWorkflow;
|
const data = JSON.parse(text) as SerializedWorkflow;
|
||||||
@@ -250,6 +262,15 @@ export async function importWorkflow(
|
|||||||
reject(new Error(`Failed to import workflow: ${message}`));
|
reject(new Error(`Failed to import workflow: ${message}`));
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const onFocus = () => {
|
||||||
|
window.removeEventListener('focus', onFocus);
|
||||||
|
setTimeout(() => {
|
||||||
|
if (!settled) resolve();
|
||||||
|
}, 300);
|
||||||
|
};
|
||||||
|
window.addEventListener('focus', onFocus);
|
||||||
|
|
||||||
input.click();
|
input.click();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
+378
-143
@@ -158,171 +158,406 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div id="file-display-area" class="mt-4 space-y-2"></div>
|
<div id="file-display-area" class="mt-4 space-y-2"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div id="options-panel" class="hidden mt-6 space-y-4">
|
<div id="editor-panel" class="hidden bg-gray-900 px-2 sm:px-4 py-4 sm:py-8">
|
||||||
<div class="flex gap-4 mb-4">
|
<div class="max-w-7xl mx-auto">
|
||||||
<label class="flex items-center gap-2 cursor-pointer">
|
<div
|
||||||
<input
|
class="flex flex-col sm:flex-row sm:items-center gap-1 sm:gap-0 sm:justify-between mb-4 sm:mb-6"
|
||||||
type="radio"
|
>
|
||||||
name="watermark-type"
|
<button
|
||||||
value="text"
|
id="editor-back-btn"
|
||||||
checked
|
class="flex items-center gap-2 text-indigo-400 hover:text-indigo-300 font-semibold text-sm"
|
||||||
class="w-4 h-4 text-indigo-600 bg-gray-700 border-gray-600 focus:ring-indigo-500"
|
>
|
||||||
/>
|
<i data-lucide="arrow-left" class="w-4 h-4"></i>
|
||||||
<span class="text-sm font-medium text-gray-300"
|
<span>Change File</span>
|
||||||
>Text Watermark</span
|
</button>
|
||||||
|
<div
|
||||||
|
id="editor-file-info"
|
||||||
|
class="text-xs sm:text-sm text-gray-400 truncate max-w-full sm:max-w-[60%]"
|
||||||
|
></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex flex-col lg:flex-row gap-4 sm:gap-6">
|
||||||
|
<div class="flex-1 min-w-0 flex flex-col">
|
||||||
|
<div
|
||||||
|
class="bg-gray-800 rounded-xl border border-gray-700 p-2 sm:p-4 flex-1 flex flex-col"
|
||||||
|
>
|
||||||
|
<div class="flex items-center justify-between mb-2 sm:mb-3">
|
||||||
|
<span class="text-xs sm:text-sm font-medium text-gray-300"
|
||||||
|
>Preview</span
|
||||||
|
>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<button
|
||||||
|
id="prev-page-btn"
|
||||||
|
disabled
|
||||||
|
class="p-1 rounded hover:bg-gray-700 text-gray-400 disabled:opacity-30 disabled:cursor-not-allowed transition-colors"
|
||||||
|
>
|
||||||
|
<i data-lucide="chevron-left" class="w-4 h-4"></i>
|
||||||
|
</button>
|
||||||
|
<div class="flex items-center gap-1 text-xs text-gray-300">
|
||||||
|
<input
|
||||||
|
id="page-num-input"
|
||||||
|
type="number"
|
||||||
|
min="1"
|
||||||
|
value="1"
|
||||||
|
class="w-10 bg-gray-700 border border-gray-600 text-white rounded text-center text-xs py-0.5"
|
||||||
|
/>
|
||||||
|
<span>/ <span id="total-pages">1</span></span>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
id="next-page-btn"
|
||||||
|
disabled
|
||||||
|
class="p-1 rounded hover:bg-gray-700 text-gray-400 disabled:opacity-30 disabled:cursor-not-allowed transition-colors"
|
||||||
|
>
|
||||||
|
<i data-lucide="chevron-right" class="w-4 h-4"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<span class="text-xs text-gray-500 hidden sm:inline"
|
||||||
|
>Drag watermark to position</span
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
id="preview-wrapper"
|
||||||
|
class="flex-1 min-h-[200px] lg:min-h-0 flex items-center justify-center bg-gray-900 rounded-lg overflow-hidden"
|
||||||
>
|
>
|
||||||
</label>
|
<div
|
||||||
<label class="flex items-center gap-2 cursor-pointer">
|
id="preview-container"
|
||||||
<input
|
class="relative overflow-hidden select-none"
|
||||||
type="radio"
|
style="touch-action: none"
|
||||||
name="watermark-type"
|
>
|
||||||
value="image"
|
<canvas id="preview-canvas"></canvas>
|
||||||
class="w-4 h-4 text-indigo-600 bg-gray-700 border-gray-600 focus:ring-indigo-500"
|
<div
|
||||||
/>
|
id="watermark-box"
|
||||||
<span class="text-sm font-medium text-gray-300"
|
class="absolute pointer-events-auto"
|
||||||
>Image Watermark</span
|
style="
|
||||||
>
|
left: 50%;
|
||||||
</label>
|
top: 50%;
|
||||||
|
transform: translate(-50%, -50%);
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="relative border border-dashed border-indigo-400/60 cursor-grab active:cursor-grabbing"
|
||||||
|
>
|
||||||
|
<div id="watermark-overlay" class="whitespace-nowrap">
|
||||||
|
CONFIDENTIAL
|
||||||
|
</div>
|
||||||
|
<img id="image-watermark-overlay" class="hidden" alt="" />
|
||||||
|
<div
|
||||||
|
data-handle="nw"
|
||||||
|
class="resize-handle absolute -top-1.5 -left-1.5 w-3 h-3 bg-white border-2 border-indigo-500 rounded-sm cursor-nw-resize pointer-events-auto z-10"
|
||||||
|
></div>
|
||||||
|
<div
|
||||||
|
data-handle="ne"
|
||||||
|
class="resize-handle absolute -top-1.5 -right-1.5 w-3 h-3 bg-white border-2 border-indigo-500 rounded-sm cursor-ne-resize pointer-events-auto z-10"
|
||||||
|
></div>
|
||||||
|
<div
|
||||||
|
data-handle="sw"
|
||||||
|
class="resize-handle absolute -bottom-1.5 -left-1.5 w-3 h-3 bg-white border-2 border-indigo-500 rounded-sm cursor-sw-resize pointer-events-auto z-10"
|
||||||
|
></div>
|
||||||
|
<div
|
||||||
|
data-handle="se"
|
||||||
|
class="resize-handle absolute -bottom-1.5 -right-1.5 w-3 h-3 bg-white border-2 border-indigo-500 rounded-sm cursor-se-resize pointer-events-auto z-10"
|
||||||
|
></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div id="text-watermark-options">
|
<div class="w-full lg:w-80 flex-shrink-0">
|
||||||
<div class="space-y-4">
|
<div
|
||||||
<div>
|
class="bg-gray-800 rounded-xl border border-gray-700 p-3 sm:p-5 space-y-3 sm:space-y-5"
|
||||||
|
>
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<button
|
||||||
|
id="type-text-btn"
|
||||||
|
class="flex-1 py-2 px-3 text-sm font-medium rounded-lg bg-indigo-600 text-white transition-colors"
|
||||||
|
>
|
||||||
|
Text
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
id="type-image-btn"
|
||||||
|
class="flex-1 py-2 px-3 text-sm font-medium rounded-lg bg-gray-700 text-gray-300 hover:bg-gray-600 transition-colors"
|
||||||
|
>
|
||||||
|
Image
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
class="flex items-center gap-2 bg-gray-900 rounded-lg px-3 py-2"
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
id="apply-all-pages"
|
||||||
|
checked
|
||||||
|
class="w-4 h-4 accent-indigo-500 bg-gray-700 border-gray-600 rounded focus:ring-indigo-500"
|
||||||
|
/>
|
||||||
<label
|
<label
|
||||||
for="watermark-text"
|
for="apply-all-pages"
|
||||||
class="block mb-2 text-sm font-medium text-gray-300"
|
class="text-sm text-gray-300 cursor-pointer"
|
||||||
>Watermark Text</label
|
data-i18n="tools:addWatermark.applyToAllPages"
|
||||||
|
>Apply to all pages</label
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="page-range-section" class="space-y-1.5">
|
||||||
|
<label
|
||||||
|
for="page-range-input"
|
||||||
|
class="block text-sm font-medium text-gray-300"
|
||||||
|
>Page Range</label
|
||||||
>
|
>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
id="watermark-text"
|
id="page-range-input"
|
||||||
placeholder="CONFIDENTIAL"
|
value="all"
|
||||||
class="w-full bg-gray-700 border border-gray-600 text-white rounded-lg p-2.5"
|
placeholder="all or 1-3, 5, 7-9"
|
||||||
|
class="w-full bg-gray-700 border border-gray-600 text-white rounded-lg p-2.5 text-sm placeholder-gray-500"
|
||||||
/>
|
/>
|
||||||
|
<p class="text-xs text-gray-500">
|
||||||
|
Use "all" or specify pages, e.g. 1-3, 5, 7-9
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="text-watermark-options">
|
||||||
|
<div class="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label
|
||||||
|
for="watermark-text"
|
||||||
|
class="block mb-1.5 text-sm font-medium text-gray-300"
|
||||||
|
>Text</label
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
id="watermark-text"
|
||||||
|
placeholder="CONFIDENTIAL"
|
||||||
|
class="w-full bg-gray-700 border border-gray-600 text-white rounded-lg p-2.5 text-sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="grid grid-cols-2 gap-3">
|
||||||
|
<div>
|
||||||
|
<label
|
||||||
|
for="font-size"
|
||||||
|
class="block mb-1.5 text-sm font-medium text-gray-300"
|
||||||
|
>Font Size</label
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
id="font-size"
|
||||||
|
value="72"
|
||||||
|
min="10"
|
||||||
|
max="200"
|
||||||
|
class="w-full bg-gray-700 border border-gray-600 text-white rounded-lg p-2.5 text-sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label
|
||||||
|
for="text-color"
|
||||||
|
class="block mb-1.5 text-sm font-medium text-gray-300"
|
||||||
|
>Color</label
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="color"
|
||||||
|
id="text-color"
|
||||||
|
value="#888888"
|
||||||
|
class=""
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label
|
||||||
|
for="opacity-text"
|
||||||
|
class="block mb-1.5 text-sm font-medium text-gray-300"
|
||||||
|
>
|
||||||
|
Opacity: <span id="opacity-value-text">0.3</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
id="opacity-text"
|
||||||
|
min="0.05"
|
||||||
|
max="1"
|
||||||
|
step="0.05"
|
||||||
|
value="0.3"
|
||||||
|
class="w-full h-2 bg-gray-700 rounded-lg cursor-pointer accent-indigo-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label
|
||||||
|
for="angle-text"
|
||||||
|
class="block mb-1.5 text-sm font-medium text-gray-300"
|
||||||
|
>
|
||||||
|
Angle: <span id="angle-value-text">-45</span>°
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
id="angle-text"
|
||||||
|
min="-90"
|
||||||
|
max="90"
|
||||||
|
step="1"
|
||||||
|
value="-45"
|
||||||
|
class="w-full h-2 bg-gray-700 rounded-lg cursor-pointer accent-indigo-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="image-watermark-options" class="hidden">
|
||||||
|
<div class="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label
|
||||||
|
for="image-watermark-input"
|
||||||
|
class="block mb-1.5 text-sm font-medium text-gray-300"
|
||||||
|
>Watermark Image</label
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="file"
|
||||||
|
id="image-watermark-input"
|
||||||
|
accept="image/png, image/jpeg"
|
||||||
|
class="w-full bg-gray-700 border border-gray-600 text-white rounded-lg p-2 text-sm file:mr-3 file:py-1.5 file:px-3 file:rounded-md file:border-0 file:text-xs file:font-semibold file:bg-indigo-600 file:text-white hover:file:bg-indigo-700"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label
|
||||||
|
for="image-scale"
|
||||||
|
class="block mb-1.5 text-sm font-medium text-gray-300"
|
||||||
|
>
|
||||||
|
Scale: <span id="image-scale-value">100</span>%
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
id="image-scale"
|
||||||
|
min="10"
|
||||||
|
max="200"
|
||||||
|
step="5"
|
||||||
|
value="100"
|
||||||
|
class="w-full h-2 bg-gray-700 rounded-lg cursor-pointer accent-indigo-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label
|
||||||
|
for="opacity-image"
|
||||||
|
class="block mb-1.5 text-sm font-medium text-gray-300"
|
||||||
|
>
|
||||||
|
Opacity: <span id="opacity-value-image">0.3</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
id="opacity-image"
|
||||||
|
min="0.05"
|
||||||
|
max="1"
|
||||||
|
step="0.05"
|
||||||
|
value="0.3"
|
||||||
|
class="w-full h-2 bg-gray-700 rounded-lg cursor-pointer accent-indigo-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label
|
||||||
|
for="angle-image"
|
||||||
|
class="block mb-1.5 text-sm font-medium text-gray-300"
|
||||||
|
>
|
||||||
|
Angle: <span id="angle-value-image">0</span>°
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
id="angle-image"
|
||||||
|
min="-90"
|
||||||
|
max="90"
|
||||||
|
step="1"
|
||||||
|
value="0"
|
||||||
|
class="w-full h-2 bg-gray-700 rounded-lg cursor-pointer accent-indigo-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="grid grid-cols-2 gap-4">
|
|
||||||
<div>
|
|
||||||
<label
|
|
||||||
for="font-size"
|
|
||||||
class="block mb-2 text-sm font-medium text-gray-300"
|
|
||||||
>Font Size</label
|
|
||||||
>
|
|
||||||
<input
|
|
||||||
type="number"
|
|
||||||
id="font-size"
|
|
||||||
value="72"
|
|
||||||
min="10"
|
|
||||||
max="200"
|
|
||||||
class="w-full bg-gray-700 border border-gray-600 text-white rounded-lg p-2.5"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label
|
|
||||||
for="text-color"
|
|
||||||
class="block mb-2 text-sm font-medium text-gray-300"
|
|
||||||
>Color</label
|
|
||||||
>
|
|
||||||
<input
|
|
||||||
type="color"
|
|
||||||
id="text-color"
|
|
||||||
value="#888888"
|
|
||||||
class="w-full h-10 bg-gray-700 border border-gray-600 rounded-lg cursor-pointer"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="grid grid-cols-2 gap-4">
|
|
||||||
<div>
|
|
||||||
<label
|
|
||||||
for="opacity-text"
|
|
||||||
class="block mb-2 text-sm font-medium text-gray-300"
|
|
||||||
>Opacity: <span id="opacity-value-text">0.3</span></label
|
|
||||||
>
|
|
||||||
<input
|
|
||||||
type="range"
|
|
||||||
id="opacity-text"
|
|
||||||
min="0.1"
|
|
||||||
max="1"
|
|
||||||
step="0.1"
|
|
||||||
value="0.3"
|
|
||||||
class="w-full h-2 bg-gray-700 rounded-lg cursor-pointer"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label
|
|
||||||
for="angle-text"
|
|
||||||
class="block mb-2 text-sm font-medium text-gray-300"
|
|
||||||
>Angle: <span id="angle-value-text">-45</span>°</label
|
|
||||||
>
|
|
||||||
<input
|
|
||||||
type="range"
|
|
||||||
id="angle-text"
|
|
||||||
min="-90"
|
|
||||||
max="90"
|
|
||||||
step="5"
|
|
||||||
value="-45"
|
|
||||||
class="w-full h-2 bg-gray-700 rounded-lg cursor-pointer"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div id="image-watermark-options" class="hidden">
|
|
||||||
<div class="space-y-4">
|
|
||||||
<div>
|
<div>
|
||||||
<label
|
<label class="block mb-1.5 text-sm font-medium text-gray-300"
|
||||||
for="image-watermark-input"
|
>Position</label
|
||||||
class="block mb-2 text-sm font-medium text-gray-300"
|
|
||||||
>Watermark Image (PNG/JPG)</label
|
|
||||||
>
|
>
|
||||||
|
<div class="grid grid-cols-3 gap-1.5">
|
||||||
|
<button
|
||||||
|
data-pos="0.15,0.15"
|
||||||
|
class="pos-preset-btn py-1.5 text-xs bg-gray-700 hover:bg-gray-600 text-gray-300 rounded-md transition-colors"
|
||||||
|
>
|
||||||
|
Top Left
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
data-pos="0.5,0.15"
|
||||||
|
class="pos-preset-btn py-1.5 text-xs bg-gray-700 hover:bg-gray-600 text-gray-300 rounded-md transition-colors"
|
||||||
|
>
|
||||||
|
Top
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
data-pos="0.85,0.15"
|
||||||
|
class="pos-preset-btn py-1.5 text-xs bg-gray-700 hover:bg-gray-600 text-gray-300 rounded-md transition-colors"
|
||||||
|
>
|
||||||
|
Top Right
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
data-pos="0.15,0.5"
|
||||||
|
class="pos-preset-btn py-1.5 text-xs bg-gray-700 hover:bg-gray-600 text-gray-300 rounded-md transition-colors"
|
||||||
|
>
|
||||||
|
Left
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
data-pos="0.5,0.5"
|
||||||
|
class="pos-preset-btn py-1.5 text-xs bg-indigo-600 hover:bg-indigo-500 text-white rounded-md transition-colors"
|
||||||
|
>
|
||||||
|
Center
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
data-pos="0.85,0.5"
|
||||||
|
class="pos-preset-btn py-1.5 text-xs bg-gray-700 hover:bg-gray-600 text-gray-300 rounded-md transition-colors"
|
||||||
|
>
|
||||||
|
Right
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
data-pos="0.15,0.85"
|
||||||
|
class="pos-preset-btn py-1.5 text-xs bg-gray-700 hover:bg-gray-600 text-gray-300 rounded-md transition-colors"
|
||||||
|
>
|
||||||
|
Bottom Left
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
data-pos="0.5,0.85"
|
||||||
|
class="pos-preset-btn py-1.5 text-xs bg-gray-700 hover:bg-gray-600 text-gray-300 rounded-md transition-colors"
|
||||||
|
>
|
||||||
|
Bottom
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
data-pos="0.85,0.85"
|
||||||
|
class="pos-preset-btn py-1.5 text-xs bg-gray-700 hover:bg-gray-600 text-gray-300 rounded-md transition-colors"
|
||||||
|
>
|
||||||
|
Bottom Right
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex items-start gap-3 bg-gray-900 rounded-lg p-3">
|
||||||
<input
|
<input
|
||||||
type="file"
|
type="checkbox"
|
||||||
id="image-watermark-input"
|
id="flatten-watermark"
|
||||||
accept="image/png, image/jpeg"
|
class="mt-0.5 w-4 h-4 text-indigo-600 bg-gray-700 border-gray-600 rounded focus:ring-indigo-500"
|
||||||
class="w-full bg-gray-700 border border-gray-600 text-white rounded-lg p-2.5 file:mr-4 file:py-2 file:px-4 file:rounded-lg file:border-0 file:text-sm file:font-semibold file:bg-indigo-600 file:text-white hover:file:bg-indigo-700"
|
|
||||||
/>
|
/>
|
||||||
</div>
|
|
||||||
<div class="grid grid-cols-2 gap-4">
|
|
||||||
<div>
|
<div>
|
||||||
<label
|
<label
|
||||||
for="opacity-image"
|
for="flatten-watermark"
|
||||||
class="block mb-2 text-sm font-medium text-gray-300"
|
class="text-sm font-medium text-gray-300 cursor-pointer"
|
||||||
>Opacity: <span id="opacity-value-image">0.3</span></label
|
>Flatten watermark</label
|
||||||
>
|
>
|
||||||
<input
|
<p class="text-xs text-gray-500 mt-0.5">
|
||||||
type="range"
|
Bakes the watermark into page pixels, making it
|
||||||
id="opacity-image"
|
tamper-resistant. Text will no longer be selectable.
|
||||||
min="0.1"
|
</p>
|
||||||
max="1"
|
|
||||||
step="0.1"
|
|
||||||
value="0.3"
|
|
||||||
class="w-full h-2 bg-gray-700 rounded-lg cursor-pointer"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label
|
|
||||||
for="angle-image"
|
|
||||||
class="block mb-2 text-sm font-medium text-gray-300"
|
|
||||||
>Angle: <span id="angle-value-image">0</span>°</label
|
|
||||||
>
|
|
||||||
<input
|
|
||||||
type="range"
|
|
||||||
id="angle-image"
|
|
||||||
min="-90"
|
|
||||||
max="90"
|
|
||||||
step="5"
|
|
||||||
value="0"
|
|
||||||
class="w-full h-2 bg-gray-700 rounded-lg cursor-pointer"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<button id="process-btn" class="btn-gradient w-full">
|
||||||
|
Add Watermark
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button id="process-btn" class="btn-gradient w-full mt-4">
|
|
||||||
Add Watermark
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -182,12 +182,7 @@
|
|||||||
class="block mb-2 text-sm font-medium text-gray-300"
|
class="block mb-2 text-sm font-medium text-gray-300"
|
||||||
>Background Color</label
|
>Background Color</label
|
||||||
>
|
>
|
||||||
<input
|
<input type="color" id="background-color" value="#FFFFCC" />
|
||||||
type="color"
|
|
||||||
id="background-color"
|
|
||||||
value="#FFFFCC"
|
|
||||||
class="w-full h-12 bg-gray-700 border border-gray-600 rounded-lg cursor-pointer"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
<button id="process-btn" class="btn-gradient w-full mt-4">
|
<button id="process-btn" class="btn-gradient w-full mt-4">
|
||||||
Change Background Color
|
Change Background Color
|
||||||
|
|||||||
@@ -320,12 +320,7 @@
|
|||||||
class="block mb-2 text-sm font-medium text-gray-300"
|
class="block mb-2 text-sm font-medium text-gray-300"
|
||||||
>Color</label
|
>Color</label
|
||||||
>
|
>
|
||||||
<input
|
<input type="color" id="text-color" value="#000000" />
|
||||||
type="color"
|
|
||||||
id="text-color"
|
|
||||||
value="#000000"
|
|
||||||
class="w-full h-10 bg-gray-700 border border-gray-600 rounded-lg cursor-pointer"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -217,12 +217,7 @@
|
|||||||
class="block mb-2 text-sm font-medium text-gray-300"
|
class="block mb-2 text-sm font-medium text-gray-300"
|
||||||
>Background Color</label
|
>Background Color</label
|
||||||
>
|
>
|
||||||
<input
|
<input type="color" id="background-color" value="#FFFFFF" />
|
||||||
type="color"
|
|
||||||
id="background-color"
|
|
||||||
value="#FFFFFF"
|
|
||||||
class="w-full h-10 bg-gray-700 border border-gray-600 rounded-lg cursor-pointer"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-end">
|
<div class="flex items-end">
|
||||||
<label class="flex items-center gap-2 cursor-pointer">
|
<label class="flex items-center gap-2 cursor-pointer">
|
||||||
@@ -261,12 +256,7 @@
|
|||||||
class="block mb-2 text-sm font-medium text-gray-300"
|
class="block mb-2 text-sm font-medium text-gray-300"
|
||||||
>Separator Color</label
|
>Separator Color</label
|
||||||
>
|
>
|
||||||
<input
|
<input type="color" id="separator-color" value="#000000" />
|
||||||
type="color"
|
|
||||||
id="separator-color"
|
|
||||||
value="#000000"
|
|
||||||
class="w-full h-10 bg-gray-700 border border-gray-600 rounded-lg cursor-pointer"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -141,6 +141,12 @@
|
|||||||
>
|
>
|
||||||
<span data-i18n="upload.orDragAndDrop">or drag and drop</span>
|
<span data-i18n="upload.orDragAndDrop">or drag and drop</span>
|
||||||
</p>
|
</p>
|
||||||
|
<p
|
||||||
|
class="text-xs text-gray-500"
|
||||||
|
data-i18n="upload.hints.singleOrMultiplePdfs"
|
||||||
|
>
|
||||||
|
Single or multiple PDF files supported
|
||||||
|
</p>
|
||||||
<p class="text-xs text-gray-500" data-i18n="upload.filesNeverLeave">
|
<p class="text-xs text-gray-500" data-i18n="upload.filesNeverLeave">
|
||||||
Your files never leave your device.
|
Your files never leave your device.
|
||||||
</p>
|
</p>
|
||||||
@@ -150,9 +156,28 @@
|
|||||||
type="file"
|
type="file"
|
||||||
class="absolute top-0 left-0 w-full h-full opacity-0 cursor-pointer"
|
class="absolute top-0 left-0 w-full h-full opacity-0 cursor-pointer"
|
||||||
accept="application/pdf"
|
accept="application/pdf"
|
||||||
|
multiple
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Control buttons for multi-file uploads -->
|
||||||
|
<div id="file-controls" class="hidden mt-4 flex gap-3">
|
||||||
|
<button
|
||||||
|
id="add-more-btn"
|
||||||
|
class="btn bg-indigo-600 hover:bg-indigo-700 text-white font-semibold px-4 py-2 rounded-lg flex items-center gap-2"
|
||||||
|
>
|
||||||
|
<i data-lucide="plus"></i>
|
||||||
|
<span data-i18n="upload.addMore">Add More Files</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
id="clear-files-btn"
|
||||||
|
class="btn bg-gray-600 hover:bg-gray-500 text-white font-semibold px-4 py-2 rounded-lg flex items-center gap-2"
|
||||||
|
>
|
||||||
|
<i data-lucide="trash-2"></i>
|
||||||
|
<span data-i18n="upload.clearAll">Clear All</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div id="file-display-area" class="mt-4 space-y-2"></div>
|
<div id="file-display-area" class="mt-4 space-y-2"></div>
|
||||||
|
|
||||||
<div id="tool-options" class="hidden mt-6 space-y-4">
|
<div id="tool-options" class="hidden mt-6 space-y-4">
|
||||||
|
|||||||
@@ -477,12 +477,7 @@
|
|||||||
class="block text-sm font-medium text-gray-300 mb-2"
|
class="block text-sm font-medium text-gray-300 mb-2"
|
||||||
>Text Color</label
|
>Text Color</label
|
||||||
>
|
>
|
||||||
<input
|
<input type="color" id="sig-text-color" value="#000000" />
|
||||||
type="color"
|
|
||||||
id="sig-text-color"
|
|
||||||
value="#000000"
|
|
||||||
class="w-full h-10 bg-gray-700 border border-gray-600 rounded-lg cursor-pointer"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label
|
<label
|
||||||
|
|||||||
@@ -296,12 +296,7 @@
|
|||||||
>Background Color</label
|
>Background Color</label
|
||||||
>
|
>
|
||||||
<div class="flex items-center gap-3">
|
<div class="flex items-center gap-3">
|
||||||
<input
|
<input type="color" id="background-color" value="#ffffff" />
|
||||||
type="color"
|
|
||||||
id="background-color"
|
|
||||||
value="#ffffff"
|
|
||||||
class="h-10 w-20 rounded border border-gray-600 bg-gray-700 cursor-pointer"
|
|
||||||
/>
|
|
||||||
<span class="text-sm text-gray-400"
|
<span class="text-sm text-gray-400"
|
||||||
>Color for margins/padding</span
|
>Color for margins/padding</span
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -504,6 +504,16 @@
|
|||||||
<i data-lucide="image" class="w-4 h-4"></i>
|
<i data-lucide="image" class="w-4 h-4"></i>
|
||||||
<span class="text-xs">Image</span>
|
<span class="text-xs">Image</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
class="tool-item bg-gray-600 hover:bg-indigo-600 text-white p-2 rounded cursor-move transition-colors flex items-center gap-2"
|
||||||
|
draggable="true"
|
||||||
|
data-type="barcode"
|
||||||
|
title="Barcode Field"
|
||||||
|
>
|
||||||
|
<i data-lucide="qr-code" class="w-4 h-4"></i>
|
||||||
|
<span class="text-xs">Barcode</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="bg-gray-700 rounded-lg p-4 mb-4 overflow-auto w-full">
|
<div class="bg-gray-700 rounded-lg p-4 mb-4 overflow-auto w-full">
|
||||||
|
|||||||
@@ -202,12 +202,7 @@
|
|||||||
class="block mb-2 text-sm font-medium text-gray-300"
|
class="block mb-2 text-sm font-medium text-gray-300"
|
||||||
>Font Color</label
|
>Font Color</label
|
||||||
>
|
>
|
||||||
<input
|
<input type="color" id="font-color" value="#000000" class="" />
|
||||||
type="color"
|
|
||||||
id="font-color"
|
|
||||||
value="#000000"
|
|
||||||
class="w-full h-[42px] bg-gray-700 border border-gray-600 rounded-lg p-1 cursor-pointer"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -253,12 +253,7 @@
|
|||||||
class="block mb-2 text-sm font-medium text-gray-300"
|
class="block mb-2 text-sm font-medium text-gray-300"
|
||||||
>Border Color</label
|
>Border Color</label
|
||||||
>
|
>
|
||||||
<input
|
<input type="color" id="border-color" value="#000000" />
|
||||||
type="color"
|
|
||||||
id="border-color"
|
|
||||||
value="#000000"
|
|
||||||
class="w-full h-10 bg-gray-700 border border-gray-600 rounded-lg cursor-pointer"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -182,24 +182,25 @@
|
|||||||
></div>
|
></div>
|
||||||
|
|
||||||
<!-- Advanced Settings -->
|
<!-- Advanced Settings -->
|
||||||
<div id="advanced-settings" class="hidden mt-6">
|
<div id="advanced-settings" class="hidden mt-6 space-y-3">
|
||||||
<h3 class="text-lg font-semibold text-white mb-2">
|
<h3 class="text-lg font-semibold text-white">Advanced Settings</h3>
|
||||||
Advanced Settings
|
<div class="bg-gray-800/50 p-4 rounded-lg border border-gray-700">
|
||||||
</h3>
|
|
||||||
<div class="bg-gray-700 p-4 rounded-lg">
|
|
||||||
<label
|
<label
|
||||||
for="page-order-input"
|
for="page-order-input"
|
||||||
class="block text-sm font-medium text-gray-300 mb-2"
|
class="block text-sm font-medium text-gray-300 mb-2"
|
||||||
>Page Order (comma-separated)</label
|
>Page Order (comma-separated)</label
|
||||||
>
|
>
|
||||||
<div class="flex gap-2">
|
<div class="flex gap-3">
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
id="page-order-input"
|
id="page-order-input"
|
||||||
class="w-full bg-gray-900 text-white rounded-md px-3 py-2 text-sm focus:ring-2 focus:ring-indigo-500 border border-gray-600"
|
class="flex-1 bg-gray-900 text-white rounded-lg px-3 py-2.5 text-sm focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 border border-gray-600 placeholder-gray-500"
|
||||||
placeholder="e.g., 3,1,4,2"
|
placeholder="e.g., 3,1,4,2"
|
||||||
/>
|
/>
|
||||||
<button id="apply-order-btn" class="btn-secondary">
|
<button
|
||||||
|
id="apply-order-btn"
|
||||||
|
class="btn-gradient whitespace-nowrap !px-5 !py-2.5 text-sm"
|
||||||
|
>
|
||||||
Apply Order
|
Apply Order
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -219,12 +219,7 @@
|
|||||||
class="block mb-2 text-sm font-medium text-gray-300"
|
class="block mb-2 text-sm font-medium text-gray-300"
|
||||||
>Color</label
|
>Color</label
|
||||||
>
|
>
|
||||||
<input
|
<input type="color" id="text-color" value="#000000" />
|
||||||
type="color"
|
|
||||||
id="text-color"
|
|
||||||
value="#000000"
|
|
||||||
class="w-full h-10 bg-gray-700 border border-gray-600 rounded-lg cursor-pointer"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -193,8 +193,12 @@
|
|||||||
step="5"
|
step="5"
|
||||||
class="w-full h-2 bg-gray-700 rounded-lg appearance-none cursor-pointer"
|
class="w-full h-2 bg-gray-700 rounded-lg appearance-none cursor-pointer"
|
||||||
/>
|
/>
|
||||||
<p class="text-xs text-gray-400 mt-1">
|
<p
|
||||||
Higher values detect more pages as blank
|
class="text-xs text-gray-400 mt-1"
|
||||||
|
data-i18n="tools:removeBlankPages.sensitivityHint"
|
||||||
|
>
|
||||||
|
Higher = stricter, only purely blank pages. Lower = allows pages
|
||||||
|
with some content.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<button id="detect-btn" class="btn-gradient w-full mt-4">
|
<button id="detect-btn" class="btn-gradient w-full mt-4">
|
||||||
@@ -210,7 +214,7 @@
|
|||||||
<p id="preview-info" class="text-gray-400 text-sm mb-4"></p>
|
<p id="preview-info" class="text-gray-400 text-sm mb-4"></p>
|
||||||
<div
|
<div
|
||||||
id="blank-pages-preview"
|
id="blank-pages-preview"
|
||||||
class="grid grid-cols-3 md:grid-cols-4 gap-3 max-h-64 overflow-y-auto p-2 bg-gray-900 rounded-lg border border-gray-700"
|
class="grid grid-cols-3 md:grid-cols-4 gap-3 p-2 bg-gray-900 rounded-lg border border-gray-700"
|
||||||
></div>
|
></div>
|
||||||
<button id="process-btn" class="btn-gradient w-full mt-4">
|
<button id="process-btn" class="btn-gradient w-full mt-4">
|
||||||
Remove Selected Blank Pages
|
Remove Selected Blank Pages
|
||||||
|
|||||||
@@ -169,12 +169,7 @@
|
|||||||
class="block mb-2 text-sm font-medium text-gray-300"
|
class="block mb-2 text-sm font-medium text-gray-300"
|
||||||
>New Text Color</label
|
>New Text Color</label
|
||||||
>
|
>
|
||||||
<input
|
<input type="color" id="text-color-input" value="#0000FF" />
|
||||||
type="color"
|
|
||||||
id="text-color-input"
|
|
||||||
value="#0000FF"
|
|
||||||
class="w-full h-12 bg-gray-700 border border-gray-600 rounded-lg cursor-pointer"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
<button id="process-btn" class="btn-gradient w-full mt-4">
|
<button id="process-btn" class="btn-gradient w-full mt-4">
|
||||||
Change Text Color
|
Change Text Color
|
||||||
|
|||||||
@@ -250,12 +250,7 @@
|
|||||||
class="block mb-2 text-sm font-medium text-gray-300"
|
class="block mb-2 text-sm font-medium text-gray-300"
|
||||||
>Text Color</label
|
>Text Color</label
|
||||||
>
|
>
|
||||||
<input
|
<input type="color" id="text-color" value="#000000" />
|
||||||
type="color"
|
|
||||||
id="text-color"
|
|
||||||
value="#000000"
|
|
||||||
class="w-full h-[42px] bg-gray-700 border border-gray-600 rounded-lg p-1 cursor-pointer"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label
|
<label
|
||||||
|
|||||||
@@ -144,10 +144,10 @@
|
|||||||
<span class="text-xs text-gray-500">Recommended:</span>
|
<span class="text-xs text-gray-500">Recommended:</span>
|
||||||
<code
|
<code
|
||||||
class="text-xs text-indigo-400 bg-gray-800 px-2 py-1 rounded flex-1 truncate"
|
class="text-xs text-indigo-400 bg-gray-800 px-2 py-1 rounded flex-1 truncate"
|
||||||
>https://cdn.jsdelivr.net/npm/@bentopdf/[email protected]4/</code
|
>https://cdn.jsdelivr.net/npm/@bentopdf/[email protected]6/</code
|
||||||
>
|
>
|
||||||
<button
|
<button
|
||||||
data-copy="https://cdn.jsdelivr.net/npm/@bentopdf/[email protected]4/"
|
data-copy="https://cdn.jsdelivr.net/npm/@bentopdf/[email protected]6/"
|
||||||
class="copy-btn p-1.5 bg-gray-600 hover:bg-gray-500 rounded text-gray-300 hover:text-white transition-colors"
|
class="copy-btn p-1.5 bg-gray-600 hover:bg-gray-500 rounded text-gray-300 hover:text-white transition-colors"
|
||||||
title="Copy to clipboard"
|
title="Copy to clipboard"
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -0,0 +1,440 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import {
|
||||||
|
parseBBox,
|
||||||
|
parseBaseline,
|
||||||
|
parseTextangle,
|
||||||
|
getTextDirection,
|
||||||
|
shouldInjectWordBreaks,
|
||||||
|
normalizeText,
|
||||||
|
calculateWordTransform,
|
||||||
|
calculateSpaceTransform,
|
||||||
|
} from '../js/utils/hocr-transform';
|
||||||
|
|
||||||
|
describe('hocr-transform', () => {
|
||||||
|
describe('parseBBox', () => {
|
||||||
|
it('should parse valid bbox string', () => {
|
||||||
|
expect(parseBBox('bbox 100 200 300 400')).toEqual({
|
||||||
|
x0: 100,
|
||||||
|
y0: 200,
|
||||||
|
x1: 300,
|
||||||
|
y1: 400,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should parse bbox with other attributes', () => {
|
||||||
|
expect(parseBBox('bbox 10 20 30 40; x_wconf 95')).toEqual({
|
||||||
|
x0: 10,
|
||||||
|
y0: 20,
|
||||||
|
x1: 30,
|
||||||
|
y1: 40,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return null for missing bbox', () => {
|
||||||
|
expect(parseBBox('x_wconf 95')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return null for empty string', () => {
|
||||||
|
expect(parseBBox('')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should parse zero values', () => {
|
||||||
|
expect(parseBBox('bbox 0 0 0 0')).toEqual({
|
||||||
|
x0: 0,
|
||||||
|
y0: 0,
|
||||||
|
x1: 0,
|
||||||
|
y1: 0,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should parse large coordinates', () => {
|
||||||
|
expect(parseBBox('bbox 0 0 2480 3508')).toEqual({
|
||||||
|
x0: 0,
|
||||||
|
y0: 0,
|
||||||
|
x1: 2480,
|
||||||
|
y1: 3508,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('parseBaseline', () => {
|
||||||
|
it('should parse valid baseline', () => {
|
||||||
|
expect(parseBaseline('baseline 0.012 -5')).toEqual({
|
||||||
|
slope: 0.012,
|
||||||
|
intercept: -5,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should parse zero baseline', () => {
|
||||||
|
expect(parseBaseline('baseline 0 0')).toEqual({
|
||||||
|
slope: 0,
|
||||||
|
intercept: 0,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should parse negative slope', () => {
|
||||||
|
expect(parseBaseline('baseline -0.005 3')).toEqual({
|
||||||
|
slope: -0.005,
|
||||||
|
intercept: 3,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return defaults for missing baseline', () => {
|
||||||
|
expect(parseBaseline('bbox 100 200 300 400')).toEqual({
|
||||||
|
slope: 0,
|
||||||
|
intercept: 0,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return defaults for empty string', () => {
|
||||||
|
expect(parseBaseline('')).toEqual({
|
||||||
|
slope: 0,
|
||||||
|
intercept: 0,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle baseline with other attributes', () => {
|
||||||
|
expect(parseBaseline('bbox 10 20 30 40; baseline 0.1 -2')).toEqual({
|
||||||
|
slope: 0.1,
|
||||||
|
intercept: -2,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('parseTextangle', () => {
|
||||||
|
it('should parse valid textangle', () => {
|
||||||
|
expect(parseTextangle('textangle 90')).toBe(90);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should parse float textangle', () => {
|
||||||
|
expect(parseTextangle('textangle 1.5')).toBe(1.5);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should parse negative textangle', () => {
|
||||||
|
expect(parseTextangle('textangle -45')).toBe(-45);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return 0 for missing textangle', () => {
|
||||||
|
expect(parseTextangle('bbox 0 0 100 100')).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return 0 for empty string', () => {
|
||||||
|
expect(parseTextangle('')).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should parse zero textangle', () => {
|
||||||
|
expect(parseTextangle('textangle 0')).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getTextDirection', () => {
|
||||||
|
it('should return rtl for dir="rtl" attribute', () => {
|
||||||
|
const el = document.createElement('div');
|
||||||
|
el.setAttribute('dir', 'rtl');
|
||||||
|
expect(getTextDirection(el)).toBe('rtl');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return ltr for dir="ltr" attribute', () => {
|
||||||
|
const el = document.createElement('div');
|
||||||
|
el.setAttribute('dir', 'ltr');
|
||||||
|
expect(getTextDirection(el)).toBe('ltr');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should default to ltr when no dir attribute', () => {
|
||||||
|
const el = document.createElement('div');
|
||||||
|
expect(getTextDirection(el)).toBe('ltr');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should default to ltr for unknown dir values', () => {
|
||||||
|
const el = document.createElement('div');
|
||||||
|
el.setAttribute('dir', 'auto');
|
||||||
|
expect(getTextDirection(el)).toBe('ltr');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('shouldInjectWordBreaks', () => {
|
||||||
|
it('should return true for English', () => {
|
||||||
|
const el = document.createElement('div');
|
||||||
|
el.setAttribute('lang', 'eng');
|
||||||
|
expect(shouldInjectWordBreaks(el)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return true for no lang attribute', () => {
|
||||||
|
const el = document.createElement('div');
|
||||||
|
expect(shouldInjectWordBreaks(el)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return false for CJK languages', () => {
|
||||||
|
const cjkLangs = ['chi_sim', 'chi_tra', 'jpn', 'kor', 'zh', 'ja', 'ko'];
|
||||||
|
cjkLangs.forEach((lang) => {
|
||||||
|
const el = document.createElement('div');
|
||||||
|
el.setAttribute('lang', lang);
|
||||||
|
expect(shouldInjectWordBreaks(el)).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return true for non-CJK languages', () => {
|
||||||
|
const langs = ['fra', 'deu', 'spa', 'ara', 'hin'];
|
||||||
|
langs.forEach((lang) => {
|
||||||
|
const el = document.createElement('div');
|
||||||
|
el.setAttribute('lang', lang);
|
||||||
|
expect(shouldInjectWordBreaks(el)).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('normalizeText', () => {
|
||||||
|
it('should normalize NFKC', () => {
|
||||||
|
expect(normalizeText('\ufb01')).toBe('fi');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should keep ASCII text unchanged', () => {
|
||||||
|
expect(normalizeText('hello')).toBe('hello');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle empty string', () => {
|
||||||
|
expect(normalizeText('')).toBe('');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should normalize full-width characters', () => {
|
||||||
|
expect(normalizeText('\uff21')).toBe('A');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should normalize superscript digits', () => {
|
||||||
|
expect(normalizeText('\u00b2')).toBe('2');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('calculateWordTransform', () => {
|
||||||
|
const baseLine = {
|
||||||
|
bbox: { x0: 0, y0: 100, x1: 500, y1: 130 },
|
||||||
|
baseline: { slope: 0, intercept: 0 },
|
||||||
|
textangle: 0,
|
||||||
|
words: [],
|
||||||
|
direction: 'ltr' as const,
|
||||||
|
injectWordBreaks: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
it('should calculate position and font size', () => {
|
||||||
|
const word = {
|
||||||
|
text: 'Hello',
|
||||||
|
bbox: { x0: 10, y0: 100, x1: 60, y1: 120 },
|
||||||
|
confidence: 95,
|
||||||
|
};
|
||||||
|
const fontWidthFn = (_text: string, fontSize: number) => fontSize * 2.5;
|
||||||
|
|
||||||
|
const result = calculateWordTransform(word, baseLine, 800, fontWidthFn);
|
||||||
|
|
||||||
|
expect(result.x).toBe(10);
|
||||||
|
expect(result.y).toBe(680);
|
||||||
|
expect(result.fontSize).toBeGreaterThan(0);
|
||||||
|
expect(result.horizontalScale).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should clamp font size to max 2x word height', () => {
|
||||||
|
const word = {
|
||||||
|
text: 'x',
|
||||||
|
bbox: { x0: 0, y0: 0, x1: 1000, y1: 10 },
|
||||||
|
confidence: 95,
|
||||||
|
};
|
||||||
|
const fontWidthFn = (_text: string, _fontSize: number) => 0.001;
|
||||||
|
|
||||||
|
const result = calculateWordTransform(word, baseLine, 800, fontWidthFn);
|
||||||
|
expect(result.fontSize).toBeLessThanOrEqual(20);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should clamp font size to minimum 1', () => {
|
||||||
|
const word = {
|
||||||
|
text: 'x',
|
||||||
|
bbox: { x0: 0, y0: 0, x1: 1, y1: 10 },
|
||||||
|
confidence: 95,
|
||||||
|
};
|
||||||
|
const fontWidthFn = (_text: string, _fontSize: number) => 10000;
|
||||||
|
|
||||||
|
const result = calculateWordTransform(word, baseLine, 800, fontWidthFn);
|
||||||
|
expect(result.fontSize).toBeGreaterThanOrEqual(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle zero font width gracefully', () => {
|
||||||
|
const word = {
|
||||||
|
text: '',
|
||||||
|
bbox: { x0: 0, y0: 0, x1: 50, y1: 20 },
|
||||||
|
confidence: 95,
|
||||||
|
};
|
||||||
|
const fontWidthFn = () => 0;
|
||||||
|
|
||||||
|
const result = calculateWordTransform(word, baseLine, 800, fontWidthFn);
|
||||||
|
expect(result.horizontalScale).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should incorporate baseline slope into rotation', () => {
|
||||||
|
const slopedLine = {
|
||||||
|
...baseLine,
|
||||||
|
baseline: { slope: 0.1, intercept: 0 },
|
||||||
|
};
|
||||||
|
const word = {
|
||||||
|
text: 'Hi',
|
||||||
|
bbox: { x0: 10, y0: 100, x1: 40, y1: 115 },
|
||||||
|
confidence: 90,
|
||||||
|
};
|
||||||
|
const fontWidthFn = (_text: string, fontSize: number) => fontSize * 1.5;
|
||||||
|
|
||||||
|
const result = calculateWordTransform(word, slopedLine, 800, fontWidthFn);
|
||||||
|
expect(result.rotation).not.toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should incorporate textangle into rotation', () => {
|
||||||
|
const angledLine = { ...baseLine, textangle: 5 };
|
||||||
|
const word = {
|
||||||
|
text: 'Hi',
|
||||||
|
bbox: { x0: 10, y0: 100, x1: 40, y1: 115 },
|
||||||
|
confidence: 90,
|
||||||
|
};
|
||||||
|
const fontWidthFn = (_text: string, fontSize: number) => fontSize * 1.5;
|
||||||
|
|
||||||
|
const result = calculateWordTransform(word, angledLine, 800, fontWidthFn);
|
||||||
|
expect(result.rotation).toBe(-5);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('calculateSpaceTransform', () => {
|
||||||
|
const baseLine = {
|
||||||
|
bbox: { x0: 0, y0: 100, x1: 500, y1: 130 },
|
||||||
|
baseline: { slope: 0, intercept: 0 },
|
||||||
|
textangle: 0,
|
||||||
|
words: [],
|
||||||
|
direction: 'ltr' as const,
|
||||||
|
injectWordBreaks: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
it('should calculate space between two words', () => {
|
||||||
|
const prev = {
|
||||||
|
text: 'Hello',
|
||||||
|
bbox: { x0: 10, y0: 100, x1: 60, y1: 120 },
|
||||||
|
confidence: 95,
|
||||||
|
};
|
||||||
|
const next = {
|
||||||
|
text: 'World',
|
||||||
|
bbox: { x0: 70, y0: 100, x1: 130, y1: 120 },
|
||||||
|
confidence: 95,
|
||||||
|
};
|
||||||
|
const spaceWidthFn = (fontSize: number) => fontSize * 0.3;
|
||||||
|
|
||||||
|
const result = calculateSpaceTransform(
|
||||||
|
prev,
|
||||||
|
next,
|
||||||
|
baseLine,
|
||||||
|
800,
|
||||||
|
spaceWidthFn
|
||||||
|
);
|
||||||
|
expect(result).not.toBeNull();
|
||||||
|
expect(result!.x).toBe(60);
|
||||||
|
expect(result!.horizontalScale).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return null when gap is zero or negative', () => {
|
||||||
|
const prev = {
|
||||||
|
text: 'Hello',
|
||||||
|
bbox: { x0: 10, y0: 100, x1: 60, y1: 120 },
|
||||||
|
confidence: 95,
|
||||||
|
};
|
||||||
|
const next = {
|
||||||
|
text: 'World',
|
||||||
|
bbox: { x0: 60, y0: 100, x1: 120, y1: 120 },
|
||||||
|
confidence: 95,
|
||||||
|
};
|
||||||
|
const spaceWidthFn = (fontSize: number) => fontSize * 0.3;
|
||||||
|
|
||||||
|
expect(
|
||||||
|
calculateSpaceTransform(prev, next, baseLine, 800, spaceWidthFn)
|
||||||
|
).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return null when overlapping words', () => {
|
||||||
|
const prev = {
|
||||||
|
text: 'Hello',
|
||||||
|
bbox: { x0: 10, y0: 100, x1: 80, y1: 120 },
|
||||||
|
confidence: 95,
|
||||||
|
};
|
||||||
|
const next = {
|
||||||
|
text: 'World',
|
||||||
|
bbox: { x0: 70, y0: 100, x1: 130, y1: 120 },
|
||||||
|
confidence: 95,
|
||||||
|
};
|
||||||
|
const spaceWidthFn = (fontSize: number) => fontSize * 0.3;
|
||||||
|
|
||||||
|
expect(
|
||||||
|
calculateSpaceTransform(prev, next, baseLine, 800, spaceWidthFn)
|
||||||
|
).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return null when space width function returns 0', () => {
|
||||||
|
const prev = {
|
||||||
|
text: 'Hello',
|
||||||
|
bbox: { x0: 10, y0: 100, x1: 60, y1: 120 },
|
||||||
|
confidence: 95,
|
||||||
|
};
|
||||||
|
const next = {
|
||||||
|
text: 'World',
|
||||||
|
bbox: { x0: 70, y0: 100, x1: 130, y1: 120 },
|
||||||
|
confidence: 95,
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(
|
||||||
|
calculateSpaceTransform(prev, next, baseLine, 800, () => 0)
|
||||||
|
).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should account for baseline intercept in y position', () => {
|
||||||
|
const lineWithIntercept = {
|
||||||
|
...baseLine,
|
||||||
|
baseline: { slope: 0, intercept: -5 },
|
||||||
|
};
|
||||||
|
const prev = {
|
||||||
|
text: 'A',
|
||||||
|
bbox: { x0: 0, y0: 100, x1: 20, y1: 130 },
|
||||||
|
confidence: 90,
|
||||||
|
};
|
||||||
|
const next = {
|
||||||
|
text: 'B',
|
||||||
|
bbox: { x0: 30, y0: 100, x1: 50, y1: 130 },
|
||||||
|
confidence: 90,
|
||||||
|
};
|
||||||
|
const spaceWidthFn = (fontSize: number) => fontSize * 0.3;
|
||||||
|
|
||||||
|
const result = calculateSpaceTransform(
|
||||||
|
prev,
|
||||||
|
next,
|
||||||
|
lineWithIntercept,
|
||||||
|
800,
|
||||||
|
spaceWidthFn
|
||||||
|
);
|
||||||
|
expect(result).not.toBeNull();
|
||||||
|
expect(result!.y).toBe(800 - 130 - -5);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should use line height + intercept for font size', () => {
|
||||||
|
const prev = {
|
||||||
|
text: 'A',
|
||||||
|
bbox: { x0: 0, y0: 100, x1: 20, y1: 130 },
|
||||||
|
confidence: 90,
|
||||||
|
};
|
||||||
|
const next = {
|
||||||
|
text: 'B',
|
||||||
|
bbox: { x0: 30, y0: 100, x1: 50, y1: 130 },
|
||||||
|
confidence: 90,
|
||||||
|
};
|
||||||
|
const spaceWidthFn = (fontSize: number) => fontSize * 0.3;
|
||||||
|
|
||||||
|
const result = calculateSpaceTransform(
|
||||||
|
prev,
|
||||||
|
next,
|
||||||
|
baseLine,
|
||||||
|
800,
|
||||||
|
spaceWidthFn
|
||||||
|
);
|
||||||
|
expect(result).not.toBeNull();
|
||||||
|
expect(result!.fontSize).toBe(30);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,277 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import {
|
||||||
|
rgbToHsl,
|
||||||
|
hslToRgb,
|
||||||
|
applyGreyscale,
|
||||||
|
applyInvertColors,
|
||||||
|
} from '../js/utils/image-effects';
|
||||||
|
|
||||||
|
function createImageData(pixels: number[][]): ImageData {
|
||||||
|
const data = new Uint8ClampedArray(pixels.flat());
|
||||||
|
return {
|
||||||
|
data,
|
||||||
|
width: pixels.length,
|
||||||
|
height: 1,
|
||||||
|
colorSpace: 'srgb',
|
||||||
|
} as ImageData;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('image-effects', () => {
|
||||||
|
describe('rgbToHsl', () => {
|
||||||
|
it('should convert pure red', () => {
|
||||||
|
const [h, s, l] = rgbToHsl(255, 0, 0);
|
||||||
|
expect(h).toBeCloseTo(0, 2);
|
||||||
|
expect(s).toBeCloseTo(1, 2);
|
||||||
|
expect(l).toBeCloseTo(0.5, 2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should convert pure green', () => {
|
||||||
|
const [h, s, l] = rgbToHsl(0, 255, 0);
|
||||||
|
expect(h).toBeCloseTo(1 / 3, 2);
|
||||||
|
expect(s).toBeCloseTo(1, 2);
|
||||||
|
expect(l).toBeCloseTo(0.5, 2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should convert pure blue', () => {
|
||||||
|
const [h, s, l] = rgbToHsl(0, 0, 255);
|
||||||
|
expect(h).toBeCloseTo(2 / 3, 2);
|
||||||
|
expect(s).toBeCloseTo(1, 2);
|
||||||
|
expect(l).toBeCloseTo(0.5, 2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should convert white', () => {
|
||||||
|
const [h, s, l] = rgbToHsl(255, 255, 255);
|
||||||
|
expect(h).toBe(0);
|
||||||
|
expect(s).toBe(0);
|
||||||
|
expect(l).toBeCloseTo(1, 2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should convert black', () => {
|
||||||
|
const [h, s, l] = rgbToHsl(0, 0, 0);
|
||||||
|
expect(h).toBe(0);
|
||||||
|
expect(s).toBe(0);
|
||||||
|
expect(l).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should convert mid gray', () => {
|
||||||
|
const [h, s, l] = rgbToHsl(128, 128, 128);
|
||||||
|
expect(h).toBe(0);
|
||||||
|
expect(s).toBe(0);
|
||||||
|
expect(l).toBeCloseTo(0.502, 2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should convert yellow', () => {
|
||||||
|
const [h, s, l] = rgbToHsl(255, 255, 0);
|
||||||
|
expect(h).toBeCloseTo(1 / 6, 2);
|
||||||
|
expect(s).toBeCloseTo(1, 2);
|
||||||
|
expect(l).toBeCloseTo(0.5, 2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should convert cyan', () => {
|
||||||
|
const [h, s, l] = rgbToHsl(0, 255, 255);
|
||||||
|
expect(h).toBeCloseTo(0.5, 2);
|
||||||
|
expect(s).toBeCloseTo(1, 2);
|
||||||
|
expect(l).toBeCloseTo(0.5, 2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should convert magenta', () => {
|
||||||
|
const [h, s, l] = rgbToHsl(255, 0, 255);
|
||||||
|
expect(h).toBeCloseTo(5 / 6, 2);
|
||||||
|
expect(s).toBeCloseTo(1, 2);
|
||||||
|
expect(l).toBeCloseTo(0.5, 2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle dark colors (l < 0.5)', () => {
|
||||||
|
const [h, s, l] = rgbToHsl(128, 0, 0);
|
||||||
|
expect(h).toBeCloseTo(0, 2);
|
||||||
|
expect(s).toBeCloseTo(1, 2);
|
||||||
|
expect(l).toBeCloseTo(0.251, 2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle light colors (l > 0.5)', () => {
|
||||||
|
const [h, s, l] = rgbToHsl(255, 128, 128);
|
||||||
|
expect(l).toBeGreaterThan(0.5);
|
||||||
|
expect(s).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('hslToRgb', () => {
|
||||||
|
it('should convert pure red', () => {
|
||||||
|
expect(hslToRgb(0, 1, 0.5)).toEqual([255, 0, 0]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should convert pure green', () => {
|
||||||
|
expect(hslToRgb(1 / 3, 1, 0.5)).toEqual([0, 255, 0]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should convert pure blue', () => {
|
||||||
|
expect(hslToRgb(2 / 3, 1, 0.5)).toEqual([0, 0, 255]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should convert white', () => {
|
||||||
|
expect(hslToRgb(0, 0, 1)).toEqual([255, 255, 255]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should convert black', () => {
|
||||||
|
expect(hslToRgb(0, 0, 0)).toEqual([0, 0, 0]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should convert gray (zero saturation)', () => {
|
||||||
|
const [r, g, b] = hslToRgb(0, 0, 0.5);
|
||||||
|
expect(r).toBe(g);
|
||||||
|
expect(g).toBe(b);
|
||||||
|
expect(r).toBe(128);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should convert yellow', () => {
|
||||||
|
expect(hslToRgb(1 / 6, 1, 0.5)).toEqual([255, 255, 0]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should convert cyan', () => {
|
||||||
|
expect(hslToRgb(0.5, 1, 0.5)).toEqual([0, 255, 255]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle different saturation values', () => {
|
||||||
|
const [r1] = hslToRgb(0, 0.5, 0.5);
|
||||||
|
const [r2] = hslToRgb(0, 1, 0.5);
|
||||||
|
expect(r2).toBeGreaterThan(r1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle l < 0.5 branch', () => {
|
||||||
|
const result = hslToRgb(0, 1, 0.25);
|
||||||
|
expect(result[0]).toBe(128);
|
||||||
|
expect(result[1]).toBe(0);
|
||||||
|
expect(result[2]).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle l >= 0.5 branch', () => {
|
||||||
|
const result = hslToRgb(0, 1, 0.75);
|
||||||
|
expect(result[0]).toBe(255);
|
||||||
|
expect(result[1]).toBe(128);
|
||||||
|
expect(result[2]).toBe(128);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('rgbToHsl <-> hslToRgb round-trip', () => {
|
||||||
|
const testColors: [number, number, number][] = [
|
||||||
|
[255, 0, 0],
|
||||||
|
[0, 255, 0],
|
||||||
|
[0, 0, 255],
|
||||||
|
[255, 255, 0],
|
||||||
|
[0, 255, 255],
|
||||||
|
[255, 0, 255],
|
||||||
|
[128, 128, 128],
|
||||||
|
[200, 100, 50],
|
||||||
|
[50, 150, 200],
|
||||||
|
[10, 10, 10],
|
||||||
|
[245, 245, 245],
|
||||||
|
];
|
||||||
|
|
||||||
|
testColors.forEach(([r, g, b]) => {
|
||||||
|
it(`should round-trip rgb(${r}, ${g}, ${b})`, () => {
|
||||||
|
const [h, s, l] = rgbToHsl(r, g, b);
|
||||||
|
const [r2, g2, b2] = hslToRgb(h, s, l);
|
||||||
|
expect(r2).toBeCloseTo(r, 0);
|
||||||
|
expect(g2).toBeCloseTo(g, 0);
|
||||||
|
expect(b2).toBeCloseTo(b, 0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('applyGreyscale', () => {
|
||||||
|
it('should convert colored pixel to grey using luminance weights', () => {
|
||||||
|
const imageData = createImageData([[255, 0, 0, 255]]);
|
||||||
|
applyGreyscale(imageData);
|
||||||
|
const expected = Math.round(0.299 * 255);
|
||||||
|
expect(imageData.data[0]).toBe(expected);
|
||||||
|
expect(imageData.data[1]).toBe(expected);
|
||||||
|
expect(imageData.data[2]).toBe(expected);
|
||||||
|
expect(imageData.data[3]).toBe(255);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should keep white as white', () => {
|
||||||
|
const imageData = createImageData([[255, 255, 255, 255]]);
|
||||||
|
applyGreyscale(imageData);
|
||||||
|
expect(imageData.data[0]).toBe(255);
|
||||||
|
expect(imageData.data[1]).toBe(255);
|
||||||
|
expect(imageData.data[2]).toBe(255);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should keep black as black', () => {
|
||||||
|
const imageData = createImageData([[0, 0, 0, 255]]);
|
||||||
|
applyGreyscale(imageData);
|
||||||
|
expect(imageData.data[0]).toBe(0);
|
||||||
|
expect(imageData.data[1]).toBe(0);
|
||||||
|
expect(imageData.data[2]).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should process multiple pixels', () => {
|
||||||
|
const imageData = createImageData([
|
||||||
|
[255, 0, 0, 255],
|
||||||
|
[0, 255, 0, 255],
|
||||||
|
[0, 0, 255, 255],
|
||||||
|
]);
|
||||||
|
applyGreyscale(imageData);
|
||||||
|
expect(imageData.data[0]).toBe(imageData.data[1]);
|
||||||
|
expect(imageData.data[4]).toBe(imageData.data[5]);
|
||||||
|
expect(imageData.data[8]).toBe(imageData.data[9]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not modify alpha channel', () => {
|
||||||
|
const imageData = createImageData([[100, 150, 200, 128]]);
|
||||||
|
applyGreyscale(imageData);
|
||||||
|
expect(imageData.data[3]).toBe(128);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('applyInvertColors', () => {
|
||||||
|
it('should invert black to white', () => {
|
||||||
|
const imageData = createImageData([[0, 0, 0, 255]]);
|
||||||
|
applyInvertColors(imageData);
|
||||||
|
expect(imageData.data[0]).toBe(255);
|
||||||
|
expect(imageData.data[1]).toBe(255);
|
||||||
|
expect(imageData.data[2]).toBe(255);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should invert white to black', () => {
|
||||||
|
const imageData = createImageData([[255, 255, 255, 255]]);
|
||||||
|
applyInvertColors(imageData);
|
||||||
|
expect(imageData.data[0]).toBe(0);
|
||||||
|
expect(imageData.data[1]).toBe(0);
|
||||||
|
expect(imageData.data[2]).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should invert mid-range colors', () => {
|
||||||
|
const imageData = createImageData([[100, 150, 200, 255]]);
|
||||||
|
applyInvertColors(imageData);
|
||||||
|
expect(imageData.data[0]).toBe(155);
|
||||||
|
expect(imageData.data[1]).toBe(105);
|
||||||
|
expect(imageData.data[2]).toBe(55);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not modify alpha channel', () => {
|
||||||
|
const imageData = createImageData([[100, 150, 200, 128]]);
|
||||||
|
applyInvertColors(imageData);
|
||||||
|
expect(imageData.data[3]).toBe(128);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should be its own inverse (double invert = original)', () => {
|
||||||
|
const imageData = createImageData([[42, 128, 200, 255]]);
|
||||||
|
applyInvertColors(imageData);
|
||||||
|
applyInvertColors(imageData);
|
||||||
|
expect(imageData.data[0]).toBe(42);
|
||||||
|
expect(imageData.data[1]).toBe(128);
|
||||||
|
expect(imageData.data[2]).toBe(200);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should process multiple pixels', () => {
|
||||||
|
const imageData = createImageData([
|
||||||
|
[0, 0, 0, 255],
|
||||||
|
[255, 255, 255, 255],
|
||||||
|
]);
|
||||||
|
applyInvertColors(imageData);
|
||||||
|
expect(imageData.data[0]).toBe(255);
|
||||||
|
expect(imageData.data[4]).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,272 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import {
|
||||||
|
parsePageRange,
|
||||||
|
parseDeletePages,
|
||||||
|
mergePdfs,
|
||||||
|
splitPdf,
|
||||||
|
deletePdfPages,
|
||||||
|
rotatePdfUniform,
|
||||||
|
rotatePdfPages,
|
||||||
|
} from '../js/utils/pdf-operations';
|
||||||
|
import { PDFDocument } from 'pdf-lib';
|
||||||
|
|
||||||
|
async function createTestPdf(pageCount: number): Promise<Uint8Array> {
|
||||||
|
const doc = await PDFDocument.create();
|
||||||
|
for (let i = 0; i < pageCount; i++) {
|
||||||
|
doc.addPage([612, 792]);
|
||||||
|
}
|
||||||
|
return new Uint8Array(await doc.save());
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('pdf-operations', () => {
|
||||||
|
describe('parsePageRange', () => {
|
||||||
|
it('should parse single page', () => {
|
||||||
|
expect(parsePageRange('3', 10)).toEqual([2]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should parse multiple pages', () => {
|
||||||
|
expect(parsePageRange('1,3,5', 10)).toEqual([0, 2, 4]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should parse range', () => {
|
||||||
|
expect(parsePageRange('2-5', 10)).toEqual([1, 2, 3, 4]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should parse mixed pages and ranges', () => {
|
||||||
|
expect(parsePageRange('1,3-5,8', 10)).toEqual([0, 2, 3, 4, 7]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle spaces', () => {
|
||||||
|
expect(parsePageRange(' 1 , 3 - 5 ', 10)).toEqual([0, 2, 3, 4]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should deduplicate and sort', () => {
|
||||||
|
expect(parsePageRange('5,3,1,3-5', 10)).toEqual([0, 2, 3, 4]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should clamp start to 1', () => {
|
||||||
|
expect(parsePageRange('0-3', 10)).toEqual([0, 1, 2]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should clamp end to totalPages', () => {
|
||||||
|
expect(parsePageRange('8-15', 10)).toEqual([7, 8, 9]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should ignore pages outside bounds', () => {
|
||||||
|
expect(parsePageRange('0,11,5', 10)).toEqual([4]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle single page document', () => {
|
||||||
|
expect(parsePageRange('1', 1)).toEqual([0]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle invalid non-numeric input', () => {
|
||||||
|
expect(parsePageRange('abc', 10)).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle empty parts', () => {
|
||||||
|
expect(parsePageRange('1,,3', 10)).toEqual([0, 2]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle range with invalid end (NaN defaults to totalPages)', () => {
|
||||||
|
expect(parsePageRange('3-abc', 10)).toEqual([2, 3, 4, 5, 6, 7, 8, 9]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('parseDeletePages', () => {
|
||||||
|
it('should parse single page', () => {
|
||||||
|
expect(parseDeletePages('3', 10)).toEqual(new Set([3]));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should parse multiple pages', () => {
|
||||||
|
expect(parseDeletePages('1,3,5', 10)).toEqual(new Set([1, 3, 5]));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should parse range', () => {
|
||||||
|
expect(parseDeletePages('2-5', 10)).toEqual(new Set([2, 3, 4, 5]));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should parse mixed pages and ranges', () => {
|
||||||
|
expect(parseDeletePages('1,3-5,8', 10)).toEqual(new Set([1, 3, 4, 5, 8]));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should ignore out-of-bounds pages', () => {
|
||||||
|
expect(parseDeletePages('0,11,5', 10)).toEqual(new Set([5]));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle spaces', () => {
|
||||||
|
expect(parseDeletePages(' 1 , 3 ', 10)).toEqual(new Set([1, 3]));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should clamp range to valid bounds', () => {
|
||||||
|
expect(parseDeletePages('0-3', 10)).toEqual(new Set([1, 2, 3]));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should clamp range end to totalPages', () => {
|
||||||
|
expect(parseDeletePages('8-15', 10)).toEqual(new Set([8, 9, 10]));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return 1-indexed page numbers', () => {
|
||||||
|
const result = parseDeletePages('1', 10);
|
||||||
|
expect(result.has(1)).toBe(true);
|
||||||
|
expect(result.has(0)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle empty parts gracefully', () => {
|
||||||
|
expect(parseDeletePages('1,,5', 10)).toEqual(new Set([1, 5]));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('mergePdfs', () => {
|
||||||
|
it('should merge two single-page PDFs', async () => {
|
||||||
|
const pdf1 = await createTestPdf(1);
|
||||||
|
const pdf2 = await createTestPdf(1);
|
||||||
|
const merged = await mergePdfs([pdf1, pdf2]);
|
||||||
|
const doc = await PDFDocument.load(merged);
|
||||||
|
expect(doc.getPageCount()).toBe(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should merge multiple PDFs', async () => {
|
||||||
|
const pdfs = await Promise.all([
|
||||||
|
createTestPdf(2),
|
||||||
|
createTestPdf(3),
|
||||||
|
createTestPdf(1),
|
||||||
|
]);
|
||||||
|
const merged = await mergePdfs(pdfs);
|
||||||
|
const doc = await PDFDocument.load(merged);
|
||||||
|
expect(doc.getPageCount()).toBe(6);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle single PDF input', async () => {
|
||||||
|
const pdf = await createTestPdf(3);
|
||||||
|
const merged = await mergePdfs([pdf]);
|
||||||
|
const doc = await PDFDocument.load(merged);
|
||||||
|
expect(doc.getPageCount()).toBe(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle empty array', async () => {
|
||||||
|
const merged = await mergePdfs([]);
|
||||||
|
const doc = await PDFDocument.load(merged);
|
||||||
|
expect(doc.getPageCount()).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('splitPdf', () => {
|
||||||
|
it('should extract specific pages', async () => {
|
||||||
|
const pdf = await createTestPdf(5);
|
||||||
|
const split = await splitPdf(pdf, [0, 2, 4]);
|
||||||
|
const doc = await PDFDocument.load(split);
|
||||||
|
expect(doc.getPageCount()).toBe(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should extract single page', async () => {
|
||||||
|
const pdf = await createTestPdf(5);
|
||||||
|
const split = await splitPdf(pdf, [2]);
|
||||||
|
const doc = await PDFDocument.load(split);
|
||||||
|
expect(doc.getPageCount()).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle extracting all pages', async () => {
|
||||||
|
const pdf = await createTestPdf(3);
|
||||||
|
const split = await splitPdf(pdf, [0, 1, 2]);
|
||||||
|
const doc = await PDFDocument.load(split);
|
||||||
|
expect(doc.getPageCount()).toBe(3);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('deletePdfPages', () => {
|
||||||
|
it('should delete specified pages', async () => {
|
||||||
|
const pdf = await createTestPdf(5);
|
||||||
|
const result = await deletePdfPages(pdf, new Set([1, 3]));
|
||||||
|
const doc = await PDFDocument.load(result);
|
||||||
|
expect(doc.getPageCount()).toBe(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should delete single page', async () => {
|
||||||
|
const pdf = await createTestPdf(3);
|
||||||
|
const result = await deletePdfPages(pdf, new Set([2]));
|
||||||
|
const doc = await PDFDocument.load(result);
|
||||||
|
expect(doc.getPageCount()).toBe(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should throw when deleting all pages', async () => {
|
||||||
|
const pdf = await createTestPdf(2);
|
||||||
|
await expect(deletePdfPages(pdf, new Set([1, 2]))).rejects.toThrow(
|
||||||
|
'Cannot delete all pages'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should ignore out-of-range page numbers', async () => {
|
||||||
|
const pdf = await createTestPdf(3);
|
||||||
|
const result = await deletePdfPages(pdf, new Set([5, 10]));
|
||||||
|
const doc = await PDFDocument.load(result);
|
||||||
|
expect(doc.getPageCount()).toBe(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle deleting no pages (empty set)', async () => {
|
||||||
|
const pdf = await createTestPdf(3);
|
||||||
|
const result = await deletePdfPages(pdf, new Set());
|
||||||
|
const doc = await PDFDocument.load(result);
|
||||||
|
expect(doc.getPageCount()).toBe(3);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('rotatePdfUniform', () => {
|
||||||
|
it('should rotate all pages by 90 degrees', async () => {
|
||||||
|
const pdf = await createTestPdf(3);
|
||||||
|
const rotated = await rotatePdfUniform(pdf, 90);
|
||||||
|
const doc = await PDFDocument.load(rotated);
|
||||||
|
expect(doc.getPageCount()).toBe(3);
|
||||||
|
expect(doc.getPage(0).getRotation().angle).toBe(90);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should rotate by 180 degrees', async () => {
|
||||||
|
const pdf = await createTestPdf(2);
|
||||||
|
const rotated = await rotatePdfUniform(pdf, 180);
|
||||||
|
const doc = await PDFDocument.load(rotated);
|
||||||
|
expect(doc.getPage(0).getRotation().angle).toBe(180);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle 0 degree rotation', async () => {
|
||||||
|
const pdf = await createTestPdf(2);
|
||||||
|
const rotated = await rotatePdfUniform(pdf, 0);
|
||||||
|
const doc = await PDFDocument.load(rotated);
|
||||||
|
expect(doc.getPage(0).getRotation().angle).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should rotate by 270 degrees', async () => {
|
||||||
|
const pdf = await createTestPdf(1);
|
||||||
|
const rotated = await rotatePdfUniform(pdf, 270);
|
||||||
|
const doc = await PDFDocument.load(rotated);
|
||||||
|
expect(doc.getPage(0).getRotation().angle).toBe(270);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('rotatePdfPages', () => {
|
||||||
|
it('should rotate individual pages by different angles', async () => {
|
||||||
|
const pdf = await createTestPdf(3);
|
||||||
|
const rotated = await rotatePdfPages(pdf, [90, 0, 180]);
|
||||||
|
const doc = await PDFDocument.load(rotated);
|
||||||
|
expect(doc.getPage(0).getRotation().angle).toBe(90);
|
||||||
|
expect(doc.getPage(1).getRotation().angle).toBe(0);
|
||||||
|
expect(doc.getPage(2).getRotation().angle).toBe(180);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should treat missing rotations as 0', async () => {
|
||||||
|
const pdf = await createTestPdf(3);
|
||||||
|
const rotated = await rotatePdfPages(pdf, [90]);
|
||||||
|
const doc = await PDFDocument.load(rotated);
|
||||||
|
expect(doc.getPage(0).getRotation().angle).toBe(90);
|
||||||
|
expect(doc.getPage(1).getRotation().angle).toBe(0);
|
||||||
|
expect(doc.getPage(2).getRotation().angle).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle all zeros', async () => {
|
||||||
|
const pdf = await createTestPdf(2);
|
||||||
|
const rotated = await rotatePdfPages(pdf, [0, 0]);
|
||||||
|
const doc = await PDFDocument.load(rotated);
|
||||||
|
expect(doc.getPage(0).getRotation().angle).toBe(0);
|
||||||
|
expect(doc.getPage(1).getRotation().angle).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -19,7 +19,7 @@ describe('Tool Configuration Arrays', () => {
|
|||||||
|
|
||||||
it('should have the correct number of tools', () => {
|
it('should have the correct number of tools', () => {
|
||||||
// This acts as a snapshot test to catch unexpected additions/removals.
|
// This acts as a snapshot test to catch unexpected additions/removals.
|
||||||
expect(singlePdfLoadTools).toHaveLength(41);
|
expect(singlePdfLoadTools).toHaveLength(42);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should not contain any duplicate tools', () => {
|
it('should not contain any duplicate tools', () => {
|
||||||
@@ -61,7 +61,7 @@ describe('Tool Configuration Arrays', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should have the correct number of tools', () => {
|
it('should have the correct number of tools', () => {
|
||||||
expect(multiFileTools).toHaveLength(13);
|
expect(multiFileTools).toHaveLength(18);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should not contain any duplicate tools', () => {
|
it('should not contain any duplicate tools', () => {
|
||||||
|
|||||||
@@ -0,0 +1,122 @@
|
|||||||
|
import { describe, it, expect, beforeEach } from 'vitest';
|
||||||
|
import {
|
||||||
|
getRotationState,
|
||||||
|
updateRotationState,
|
||||||
|
resetRotationState,
|
||||||
|
initializeRotationState,
|
||||||
|
} from '../js/utils/rotation-state';
|
||||||
|
|
||||||
|
describe('rotation-state', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
resetRotationState();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('initializeRotationState', () => {
|
||||||
|
it('should create array of zeros for given page count', () => {
|
||||||
|
initializeRotationState(5);
|
||||||
|
expect(getRotationState()).toEqual([0, 0, 0, 0, 0]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle single page', () => {
|
||||||
|
initializeRotationState(1);
|
||||||
|
expect(getRotationState()).toEqual([0]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle zero pages', () => {
|
||||||
|
initializeRotationState(0);
|
||||||
|
expect(getRotationState()).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should reset previous state on re-initialization', () => {
|
||||||
|
initializeRotationState(3);
|
||||||
|
updateRotationState(0, 90);
|
||||||
|
initializeRotationState(5);
|
||||||
|
expect(getRotationState()).toEqual([0, 0, 0, 0, 0]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getRotationState', () => {
|
||||||
|
it('should return empty array before initialization', () => {
|
||||||
|
expect(getRotationState()).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return readonly array', () => {
|
||||||
|
initializeRotationState(3);
|
||||||
|
const state = getRotationState();
|
||||||
|
expect(Array.isArray(state)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should reflect current state after updates', () => {
|
||||||
|
initializeRotationState(3);
|
||||||
|
updateRotationState(1, 180);
|
||||||
|
expect(getRotationState()[1]).toBe(180);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('updateRotationState', () => {
|
||||||
|
it('should update rotation at valid index', () => {
|
||||||
|
initializeRotationState(3);
|
||||||
|
updateRotationState(0, 90);
|
||||||
|
expect(getRotationState()[0]).toBe(90);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should update to any rotation value', () => {
|
||||||
|
initializeRotationState(3);
|
||||||
|
updateRotationState(0, 90);
|
||||||
|
updateRotationState(1, 180);
|
||||||
|
updateRotationState(2, 270);
|
||||||
|
expect(getRotationState()).toEqual([90, 180, 270]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle negative rotation', () => {
|
||||||
|
initializeRotationState(2);
|
||||||
|
updateRotationState(0, -90);
|
||||||
|
expect(getRotationState()[0]).toBe(-90);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should ignore negative index', () => {
|
||||||
|
initializeRotationState(3);
|
||||||
|
updateRotationState(-1, 90);
|
||||||
|
expect(getRotationState()).toEqual([0, 0, 0]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should ignore out-of-bounds index', () => {
|
||||||
|
initializeRotationState(3);
|
||||||
|
updateRotationState(5, 90);
|
||||||
|
expect(getRotationState()).toEqual([0, 0, 0]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should ignore update on empty state', () => {
|
||||||
|
updateRotationState(0, 90);
|
||||||
|
expect(getRotationState()).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should allow overwriting a previously set rotation', () => {
|
||||||
|
initializeRotationState(2);
|
||||||
|
updateRotationState(0, 90);
|
||||||
|
updateRotationState(0, 180);
|
||||||
|
expect(getRotationState()[0]).toBe(180);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('resetRotationState', () => {
|
||||||
|
it('should clear all state', () => {
|
||||||
|
initializeRotationState(5);
|
||||||
|
updateRotationState(2, 270);
|
||||||
|
resetRotationState();
|
||||||
|
expect(getRotationState()).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should be safe to call on empty state', () => {
|
||||||
|
resetRotationState();
|
||||||
|
expect(getRotationState()).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should allow re-initialization after reset', () => {
|
||||||
|
initializeRotationState(3);
|
||||||
|
resetRotationState();
|
||||||
|
initializeRotationState(2);
|
||||||
|
expect(getRotationState()).toEqual([0, 0]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,5 +1,18 @@
|
|||||||
import { afterEach, vi } from 'vitest';
|
import { afterEach, vi } from 'vitest';
|
||||||
|
|
||||||
|
class TestDOMMatrix {
|
||||||
|
a = 1;
|
||||||
|
b = 0;
|
||||||
|
c = 0;
|
||||||
|
d = 1;
|
||||||
|
e = 0;
|
||||||
|
f = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof globalThis.DOMMatrix === 'undefined') {
|
||||||
|
globalThis.DOMMatrix = TestDOMMatrix as unknown as typeof DOMMatrix;
|
||||||
|
}
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
document.body.innerHTML = '';
|
document.body.innerHTML = '';
|
||||||
document.head.innerHTML = '';
|
document.head.innerHTML = '';
|
||||||
|
|||||||
@@ -0,0 +1,255 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
|
||||||
|
function groupByTagName(elements: Element[]): Record<string, Element[]> {
|
||||||
|
const groups: Record<string, Element[]> = {};
|
||||||
|
for (const element of elements) {
|
||||||
|
const tagName = element.tagName;
|
||||||
|
if (!groups[tagName]) {
|
||||||
|
groups[tagName] = [];
|
||||||
|
}
|
||||||
|
groups[tagName].push(element);
|
||||||
|
}
|
||||||
|
return groups;
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractTableData(elements: Element[]): {
|
||||||
|
headers: string[];
|
||||||
|
rows: string[][];
|
||||||
|
} {
|
||||||
|
if (elements.length === 0) {
|
||||||
|
return { headers: [], rows: [] };
|
||||||
|
}
|
||||||
|
const headerSet = new Set<string>();
|
||||||
|
for (const element of elements) {
|
||||||
|
for (const child of Array.from(element.children)) {
|
||||||
|
headerSet.add(child.tagName);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const headers = Array.from(headerSet);
|
||||||
|
const rows: string[][] = [];
|
||||||
|
for (const element of elements) {
|
||||||
|
const row: string[] = [];
|
||||||
|
for (const header of headers) {
|
||||||
|
const child = element.querySelector(header);
|
||||||
|
row.push(child?.textContent?.trim() || '');
|
||||||
|
}
|
||||||
|
rows.push(row);
|
||||||
|
}
|
||||||
|
return { headers, rows };
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractKeyValuePairs(element: Element): string[][] {
|
||||||
|
const pairs: string[][] = [];
|
||||||
|
for (const child of Array.from(element.children)) {
|
||||||
|
const key = child.tagName;
|
||||||
|
const value = child.textContent?.trim() || '';
|
||||||
|
if (value) {
|
||||||
|
pairs.push([formatTitle(key), value]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const attr of Array.from(element.attributes)) {
|
||||||
|
pairs.push([formatTitle(attr.name), attr.value]);
|
||||||
|
}
|
||||||
|
return pairs;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatTitle(tagName: string): string {
|
||||||
|
return tagName
|
||||||
|
.replace(/[_-]/g, ' ')
|
||||||
|
.replace(/([a-z])([A-Z])/g, '$1 $2')
|
||||||
|
.split(' ')
|
||||||
|
.map((word) => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase())
|
||||||
|
.join(' ');
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseXml(xmlString: string): Document {
|
||||||
|
return new DOMParser().parseFromString(xmlString, 'text/xml');
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('xml-to-pdf utilities', () => {
|
||||||
|
describe('formatTitle', () => {
|
||||||
|
it('should convert underscores to spaces and capitalize', () => {
|
||||||
|
expect(formatTitle('first_name')).toBe('First Name');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should convert hyphens to spaces and capitalize', () => {
|
||||||
|
expect(formatTitle('last-name')).toBe('Last Name');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should split camelCase', () => {
|
||||||
|
expect(formatTitle('firstName')).toBe('First Name');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle single word', () => {
|
||||||
|
expect(formatTitle('name')).toBe('Name');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle all caps', () => {
|
||||||
|
expect(formatTitle('ID')).toBe('Id');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle mixed separators', () => {
|
||||||
|
expect(formatTitle('user_firstName')).toBe('User First Name');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle empty string', () => {
|
||||||
|
expect(formatTitle('')).toBe('');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle multiple underscores', () => {
|
||||||
|
expect(formatTitle('a_b_c')).toBe('A B C');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should lowercase subsequent characters', () => {
|
||||||
|
expect(formatTitle('XML')).toBe('Xml');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('groupByTagName', () => {
|
||||||
|
it('should group elements by tag name', () => {
|
||||||
|
const doc = parseXml(
|
||||||
|
'<root><item>1</item><item>2</item><other>3</other></root>'
|
||||||
|
);
|
||||||
|
const children = Array.from(doc.documentElement.children);
|
||||||
|
const groups = groupByTagName(children);
|
||||||
|
expect(Object.keys(groups)).toEqual(['item', 'other']);
|
||||||
|
expect(groups['item'].length).toBe(2);
|
||||||
|
expect(groups['other'].length).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle empty array', () => {
|
||||||
|
expect(groupByTagName([])).toEqual({});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle single element', () => {
|
||||||
|
const doc = parseXml('<root><item>1</item></root>');
|
||||||
|
const children = Array.from(doc.documentElement.children);
|
||||||
|
const groups = groupByTagName(children);
|
||||||
|
expect(Object.keys(groups)).toEqual(['item']);
|
||||||
|
expect(groups['item'].length).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle all same tag names', () => {
|
||||||
|
const doc = parseXml('<root><row>1</row><row>2</row><row>3</row></root>');
|
||||||
|
const children = Array.from(doc.documentElement.children);
|
||||||
|
const groups = groupByTagName(children);
|
||||||
|
expect(Object.keys(groups)).toEqual(['row']);
|
||||||
|
expect(groups['row'].length).toBe(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle all different tag names', () => {
|
||||||
|
const doc = parseXml('<root><a>1</a><b>2</b><c>3</c></root>');
|
||||||
|
const children = Array.from(doc.documentElement.children);
|
||||||
|
const groups = groupByTagName(children);
|
||||||
|
expect(Object.keys(groups).length).toBe(3);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('extractTableData', () => {
|
||||||
|
it('should extract headers and rows from elements', () => {
|
||||||
|
const doc = parseXml(`
|
||||||
|
<root>
|
||||||
|
<person><name>Alice</name><age>30</age></person>
|
||||||
|
<person><name>Bob</name><age>25</age></person>
|
||||||
|
</root>
|
||||||
|
`);
|
||||||
|
const elements = Array.from(doc.querySelectorAll('person'));
|
||||||
|
const { headers, rows } = extractTableData(elements);
|
||||||
|
expect(headers).toEqual(['name', 'age']);
|
||||||
|
expect(rows).toEqual([
|
||||||
|
['Alice', '30'],
|
||||||
|
['Bob', '25'],
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle empty array', () => {
|
||||||
|
expect(extractTableData([])).toEqual({ headers: [], rows: [] });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle missing children in some elements', () => {
|
||||||
|
const doc = parseXml(`
|
||||||
|
<root>
|
||||||
|
<item><a>1</a><b>2</b></item>
|
||||||
|
<item><a>3</a></item>
|
||||||
|
</root>
|
||||||
|
`);
|
||||||
|
const elements = Array.from(doc.querySelectorAll('item'));
|
||||||
|
const { headers, rows } = extractTableData(elements);
|
||||||
|
expect(headers).toEqual(['a', 'b']);
|
||||||
|
expect(rows[1]).toEqual(['3', '']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle elements with no children', () => {
|
||||||
|
const doc = parseXml('<root><item></item></root>');
|
||||||
|
const elements = Array.from(doc.querySelectorAll('item'));
|
||||||
|
const { headers, rows } = extractTableData(elements);
|
||||||
|
expect(headers).toEqual([]);
|
||||||
|
expect(rows).toEqual([[]]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should collect headers from all elements', () => {
|
||||||
|
const doc = parseXml(`
|
||||||
|
<root>
|
||||||
|
<item><a>1</a></item>
|
||||||
|
<item><b>2</b></item>
|
||||||
|
</root>
|
||||||
|
`);
|
||||||
|
const elements = Array.from(doc.querySelectorAll('item'));
|
||||||
|
const { headers } = extractTableData(elements);
|
||||||
|
expect(headers).toContain('a');
|
||||||
|
expect(headers).toContain('b');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should trim whitespace from text content', () => {
|
||||||
|
const doc = parseXml('<root><item><val> hello </val></item></root>');
|
||||||
|
const elements = Array.from(doc.querySelectorAll('item'));
|
||||||
|
const { rows } = extractTableData(elements);
|
||||||
|
expect(rows[0][0]).toBe('hello');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('extractKeyValuePairs', () => {
|
||||||
|
it('should extract child elements as key-value pairs', () => {
|
||||||
|
const doc = parseXml(
|
||||||
|
'<config><name>Test</name><version>1.0</version></config>'
|
||||||
|
);
|
||||||
|
const pairs = extractKeyValuePairs(doc.documentElement);
|
||||||
|
expect(pairs).toEqual([
|
||||||
|
['Name', 'Test'],
|
||||||
|
['Version', '1.0'],
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should extract attributes as key-value pairs', () => {
|
||||||
|
const doc = parseXml('<config id="123" type="main"></config>');
|
||||||
|
const pairs = extractKeyValuePairs(doc.documentElement);
|
||||||
|
expect(pairs).toContainEqual(['Id', '123']);
|
||||||
|
expect(pairs).toContainEqual(['Type', 'main']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should include both children and attributes', () => {
|
||||||
|
const doc = parseXml('<config id="1"><name>Test</name></config>');
|
||||||
|
const pairs = extractKeyValuePairs(doc.documentElement);
|
||||||
|
expect(pairs.length).toBe(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should skip empty child text content', () => {
|
||||||
|
const doc = parseXml('<config><name>Test</name><empty></empty></config>');
|
||||||
|
const pairs = extractKeyValuePairs(doc.documentElement);
|
||||||
|
expect(pairs.length).toBe(1);
|
||||||
|
expect(pairs[0][0]).toBe('Name');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle element with no children or attributes', () => {
|
||||||
|
const doc = parseXml('<config></config>');
|
||||||
|
const pairs = extractKeyValuePairs(doc.documentElement);
|
||||||
|
expect(pairs).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should format tag names using formatTitle', () => {
|
||||||
|
const doc = parseXml('<config><user_name>Alice</user_name></config>');
|
||||||
|
const pairs = extractKeyValuePairs(doc.documentElement);
|
||||||
|
expect(pairs[0][0]).toBe('User Name');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -24,6 +24,7 @@ const SUPPORTED_LANGUAGES = [
|
|||||||
'it',
|
'it',
|
||||||
'nl',
|
'nl',
|
||||||
'pt',
|
'pt',
|
||||||
|
'sv',
|
||||||
'tr',
|
'tr',
|
||||||
'vi',
|
'vi',
|
||||||
'zh',
|
'zh',
|
||||||
|
|||||||
Reference in New Issue
Block a user