Compare commits

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

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

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

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

Signed-off-by: dependabot[bot] <[email protected]>
2026-02-21 08:30:54 +00:00
Alam 7313fa08a8 Merge pull request #501 from diogotcorreia/pin-xlsx
chore: pin xlsx version
2026-02-21 13:59:36 +05:30
github-actions[bot] 1174a6dbaa @diogotcorreia has signed the CLA in alam00000/bentopdf#501 2026-02-21 00:26:40 +00:00
Diogo Correia c37218b86d chore: update project version in package-lock.json 2026-02-21 01:17:32 +01:00
Diogo Correia efa91e7a72 chore: pin xlsx version
This ensures reproducibility when building this project.
2026-02-21 01:16:16 +01:00
dependabot[bot] c8391d5886 build(deps): bump jspdf from 4.1.0 to 4.2.0
Bumps [jspdf](https://github.com/parallax/jsPDF) from 4.1.0 to 4.2.0.
- [Release notes](https://github.com/parallax/jsPDF/releases)
- [Changelog](https://github.com/parallax/jsPDF/blob/master/RELEASE.md)
- [Commits](https://github.com/parallax/jsPDF/compare/v4.1.0...v4.2.0)

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

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

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

Signed-off-by: dependabot[bot] <[email protected]>
2026-02-20 11:58:48 +00:00
alam00000 e6c8eaf23c fix: improve CORS proxy handling and update documentation for certificate fetching 2026-02-20 16:07:38 +05:30
Alam 832e28aaca Merge pull request #494 from tzabbi/main
chore: update bentopdf version in helm chart
2026-02-20 15:26:34 +05:30
Tom Zaspel 404dd544ab Merge branch 'alam00000:main' into main 2026-02-19 17:33:17 +01:00
Tom Zaspel 87d37a8be4 chore: update version of bentopdf 2026-02-19 17:32:48 +01:00
Alam 561ac12304 Merge pull request #493 from tzabbi/main
fix: httproute invalid syntax and tweak chart
2026-02-19 20:50:17 +05:30
Tom Zaspel 450815db96 chore: bump version of helm chart and update appVersion 2026-02-19 15:16:44 +01:00
github-actions[bot] eea81e7fd6 @tzabbi has signed the CLA in alam00000/bentopdf#493 2026-02-19 11:27:47 +00:00
Tom Zaspel dffcfb77f3 chore: remove fullname because it is not needed 2026-02-19 12:18:33 +01:00
Tom Zaspel b4ff2f68f8 chore: replace chart value from the _helpers.tpl and set it to bentopdf 2026-02-19 11:06:50 +01:00
Tom Zaspel 1c7cc36472 fix: httpRoute template file 2026-02-18 06:48:01 +01:00
Alam b42d991157 Merge pull request #486 from pavel-miniutka/main
Belarusian translation update
2026-02-17 20:01:17 +05:30
pavel-miniutka f129b732ae Belarusian translation update 2026-02-17 15:20:27 +03:00
alam00000 179de7ef83 Release v2.2.1 2026-02-14 21:57:54 +05:30
alam00000 dc0f3b30ad add: tools to readme 2026-02-14 21:55:15 +05:30
alam00000 3cf435d59d feat: add custom branding, air-gapped deployment script, and updated self-hosting docs 2026-02-14 21:41:45 +05:30
alam00000 75b1d67fbd feat: add support for default UI language configuration and non-root Dockerfile 2026-02-13 13:45:11 +05:30
Alam d1d354359c Merge pull request #473 from Stephan-P/patch-1
Update tools.json
2026-02-12 19:48:55 +05:30
Stephan Paternotte a9e007af66 Update tools.json
Translation of Workflow builder revisited
2026-02-10 17:45:40 +01:00
111 changed files with 6409 additions and 1690 deletions
+12 -1
View File
@@ -8,6 +8,17 @@ 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/
# Default UI language (build-time)
# Supported: en, ar, be, fr, de, es, zh, zh-TW, vi, tr, id, it, pt, nl, da
VITE_DEFAULT_LANGUAGE=
# Custom branding (build-time)
# Replace the default BentoPDF branding with your own.
# Place your logo file in the public/ folder and set the path relative to it.
VITE_BRAND_NAME=
VITE_BRAND_LOGO=
VITE_FOOTER_TEXT=
+2
View File
@@ -0,0 +1,2 @@
# Handlebars partials with template syntax inside HTML attributes
src/partials/footer.html
+12
View File
@@ -35,6 +35,18 @@ ENV VITE_WASM_PYMUPDF_URL=$VITE_WASM_PYMUPDF_URL
ENV VITE_WASM_GS_URL=$VITE_WASM_GS_URL ENV VITE_WASM_GS_URL=$VITE_WASM_GS_URL
ENV VITE_WASM_CPDF_URL=$VITE_WASM_CPDF_URL ENV VITE_WASM_CPDF_URL=$VITE_WASM_CPDF_URL
# Default UI language (e.g. en, fr, de, es, zh, ar)
ARG VITE_DEFAULT_LANGUAGE
ENV VITE_DEFAULT_LANGUAGE=$VITE_DEFAULT_LANGUAGE
# Custom branding (e.g. VITE_BRAND_NAME=MyCompany VITE_BRAND_LOGO=my-logo.svg)
ARG VITE_BRAND_NAME
ARG VITE_BRAND_LOGO
ARG VITE_FOOTER_TEXT
ENV VITE_BRAND_NAME=$VITE_BRAND_NAME
ENV VITE_BRAND_LOGO=$VITE_BRAND_LOGO
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 npm run build:with-docs
+78
View File
@@ -0,0 +1,78 @@
# Non-root Dockerfile — supports PUID/PGID environment variables (LSIO-style)
# Usage: docker build -f Dockerfile.nonroot -t bentopdf .
# docker run -d -p 3000:8080 -e PUID=1000 -e PGID=1000 bentopdf
ARG BASE_URL=
# Build stage (identical to main Dockerfile)
FROM public.ecr.aws/docker/library/node:20-alpine AS builder
WORKDIR /app
COPY package*.json ./
COPY vendor ./vendor
ENV HUSKY=0
RUN npm config set fetch-retries 5 && \
npm config set fetch-retry-mintimeout 60000 && \
npm config set fetch-retry-maxtimeout 300000 && \
npm config set fetch-timeout 600000 && \
npm ci
COPY . .
ARG SIMPLE_MODE=false
ENV SIMPLE_MODE=$SIMPLE_MODE
ARG COMPRESSION_MODE=all
ENV COMPRESSION_MODE=$COMPRESSION_MODE
ARG BASE_URL
ENV BASE_URL=$BASE_URL
ARG VITE_WASM_PYMUPDF_URL
ARG VITE_WASM_GS_URL
ARG VITE_WASM_CPDF_URL
ENV VITE_WASM_PYMUPDF_URL=$VITE_WASM_PYMUPDF_URL
ENV VITE_WASM_GS_URL=$VITE_WASM_GS_URL
ENV VITE_WASM_CPDF_URL=$VITE_WASM_CPDF_URL
# Default UI language (e.g. en, fr, de, es, zh, ar)
ARG VITE_DEFAULT_LANGUAGE
ENV VITE_DEFAULT_LANGUAGE=$VITE_DEFAULT_LANGUAGE
# Custom branding (e.g. VITE_BRAND_NAME=MyCompany VITE_BRAND_LOGO=my-logo.svg)
ARG VITE_BRAND_NAME
ARG VITE_BRAND_LOGO
ARG VITE_FOOTER_TEXT
ENV VITE_BRAND_NAME=$VITE_BRAND_NAME
ENV VITE_BRAND_LOGO=$VITE_BRAND_LOGO
ENV VITE_FOOTER_TEXT=$VITE_FOOTER_TEXT
ENV NODE_OPTIONS="--max-old-space-size=3072"
RUN npm run build:with-docs
# Production stage — uses standard nginx (starts as root, drops to PUID/PGID)
FROM nginx:stable-alpine-slim
LABEL org.opencontainers.image.source="https://github.com/alam00000/bentopdf"
LABEL org.opencontainers.image.url="https://github.com/alam00000/bentopdf"
ARG BASE_URL
ENV PUID=1000
ENV PGID=1000
ENV DISABLE_IPV6=false
RUN apk add --no-cache su-exec
COPY --from=builder /app/dist /usr/share/nginx/html${BASE_URL%/}
COPY nginx.conf /etc/nginx/nginx.conf
COPY --chmod=755 entrypoint.sh /entrypoint.sh
RUN mkdir -p /etc/nginx/tmp \
/var/cache/nginx/client_temp \
/var/cache/nginx/proxy_temp \
/var/cache/nginx/fastcgi_temp \
/var/cache/nginx/uwsgi_temp \
/var/cache/nginx/scgi_temp
EXPOSE 8080
ENTRYPOINT ["/entrypoint.sh"]
CMD ["nginx", "-g", "daemon off;"]
+175 -27
View File
@@ -38,6 +38,7 @@
- [Docker Compose / Podman Compose](#-run-with-docker-compose--podman-compose-recommended) - [Docker Compose / Podman Compose](#-run-with-docker-compose--podman-compose-recommended)
- [Podman Quadlet](#-podman-quadlet-systemd-integration) - [Podman Quadlet](#-podman-quadlet-systemd-integration)
- [Simple Mode](#-simple-mode-for-internal-use) - [Simple Mode](#-simple-mode-for-internal-use)
- [Custom Branding](#-custom-branding)
- [WASM Configuration](#wasm-configuration) - [WASM Configuration](#wasm-configuration)
- [Air-Gapped / Offline Deployment](#air-gapped--offline-deployment) - [Air-Gapped / Offline Deployment](#air-gapped--offline-deployment)
- [Security Features](#-security-features) - [Security Features](#-security-features)
@@ -174,6 +175,7 @@ BentoPDF offers a comprehensive suite of tools to handle all your PDF needs.
| **Create Fillable Forms** | Create professional fillable PDF forms with text fields, checkboxes, dropdowns, radio buttons, signatures, and more. Fully compliant with PDF standards for compatibility with all PDF viewers. | | **Create Fillable Forms** | Create professional fillable PDF forms with text fields, checkboxes, dropdowns, radio buttons, signatures, and more. Fully compliant with PDF standards for compatibility with all PDF viewers. |
| **PDF Form Filler** | Fill in forms directly in the browser. Also supports XFA forms. | | **PDF Form Filler** | Fill in forms directly in the browser. Also supports XFA forms. |
| **Add Page Numbers** | Easily add page numbers with customizable formatting. | | **Add Page Numbers** | Easily add page numbers with customizable formatting. |
| **Bates Numbering** | Add sequential Bates numbers across one or more PDF files. |
| **Add Watermark** | Add text or image watermarks to protect your documents. | | **Add Watermark** | Add text or image watermarks to protect your documents. |
| **Header & Footer** | Add customizable headers and footers. | | **Header & Footer** | Add customizable headers and footers. |
| **Crop PDF** | Crop specific pages or the entire document. | | **Crop PDF** | Crop specific pages or the entire document. |
@@ -189,6 +191,14 @@ BentoPDF offers a comprehensive suite of tools to handle all your PDF needs.
| **Add Stamps** | Add image stamps to your PDF using the annotation toolbar. | | **Add Stamps** | Add image stamps to your PDF using the annotation toolbar. |
| **Table of Contents** | Generate a table of contents page from PDF bookmarks. | | **Table of Contents** | Generate a table of contents page from PDF bookmarks. |
| **Redact Content** | Permanently remove sensitive content from your PDFs. | | **Redact Content** | Permanently remove sensitive content from your PDFs. |
| **Scanner Effect** | Make your PDF look like a scanned document. |
| **Adjust Colors** | Fine-tune brightness, contrast, saturation and more. |
### Automate
| Tool Name | Description |
| :----------------------- | :--------------------------------------------------------------- |
| **PDF Workflow Builder** | Build custom PDF processing pipelines with a visual node editor. |
### Convert to PDF ### Convert to PDF
@@ -448,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/
``` ```
@@ -472,7 +482,69 @@ Users can also override these defaults per-browser via **Advanced Settings** in
<h3 id="air-gapped--offline-deployment">🔒 Air-Gapped / Offline Deployment</h3> <h3 id="air-gapped--offline-deployment">🔒 Air-Gapped / Offline Deployment</h3>
For networks with no internet access (government, healthcare, financial, etc.), you need to prepare everything on a machine **with** internet, then transfer it into the isolated network. For networks with no internet access (government, healthcare, financial, etc.), you need to prepare everything on a machine **with** internet, then transfer the bundle into the isolated network.
#### Automated Script (Recommended)
The included `prepare-airgap.sh` script automates the entire process — downloading WASM packages, building the Docker image, exporting everything into a self-contained bundle with a setup script.
```bash
git clone https://github.com/alam00000/bentopdf.git
cd bentopdf
# Interactive mode — prompts for all options
bash scripts/prepare-airgap.sh
# Or fully automated
bash scripts/prepare-airgap.sh --wasm-base-url https://internal.example.com/wasm
```
This produces a bundle directory containing:
```
bentopdf-airgap-bundle/
bentopdf.tar # Docker image
*.tgz # WASM packages (PyMuPDF, Ghostscript, CoherentPDF)
setup.sh # Setup script for the air-gapped side
README.md # Instructions
```
**Transfer the bundle** into the air-gapped network via USB, internal artifact repo, or approved method. Then run the included setup script:
```bash
cd bentopdf-airgap-bundle
bash setup.sh
```
The setup script loads the Docker image, extracts WASM files, and optionally starts the container.
<details>
<summary><strong>Script options</strong></summary>
| Flag | Description | Default |
| ----------------------- | ------------------------------------------------ | --------------------------------- |
| `--wasm-base-url <url>` | Where WASMs will be hosted internally | _(required, prompted if missing)_ |
| `--image-name <name>` | Docker image tag | `bentopdf` |
| `--output-dir <path>` | Output bundle directory | `./bentopdf-airgap-bundle` |
| `--simple-mode` | Enable Simple Mode | off |
| `--base-url <path>` | Subdirectory base URL (e.g. `/pdf/`) | `/` |
| `--language <code>` | Default UI language (e.g. `fr`, `de`) | _(none)_ |
| `--brand-name <name>` | Custom brand name | _(none)_ |
| `--brand-logo <path>` | Logo path relative to `public/` | _(none)_ |
| `--footer-text <text>` | Custom footer text | _(none)_ |
| `--dockerfile <path>` | Dockerfile to use | `Dockerfile` |
| `--skip-docker` | Skip Docker build and export | off |
| `--skip-wasm` | Skip WASM download (reuse existing `.tgz` files) | off |
</details>
> [!IMPORTANT]
> WASM files must be served from the **same origin** as the BentoPDF app. Web Workers use `importScripts()` which cannot load scripts cross-origin. For example, if BentoPDF runs at `https://internal.example.com`, the WASM base URL should also be `https://internal.example.com/wasm`.
#### Manual Steps
<details>
<summary>If you prefer to do it manually without the script</summary>
**Step 1: Download the WASM packages** (on a machine with internet) **Step 1: Download the WASM packages** (on a machine with internet)
@@ -482,14 +554,9 @@ npm pack @bentopdf/gs-wasm
npm pack coherentpdf npm pack coherentpdf
``` ```
This creates three `.tgz` files in your current directory. **Step 2: Build the Docker image with internal URLs**
**Step 2: Build the Docker image with internal URLs** (on a machine with internet)
Point the WASM URLs to where you'll host the files inside the air-gapped network:
```bash ```bash
# Clone and build
git clone https://github.com/alam00000/bentopdf.git git clone https://github.com/alam00000/bentopdf.git
cd bentopdf cd bentopdf
@@ -522,19 +589,18 @@ Copy these files via USB drive, internal artifact repository, or approved transf
docker load -i bentopdf.tar docker load -i bentopdf.tar
# Extract the WASM packages # Extract the WASM packages
mkdir -p /var/www/wasm/pymupdf /var/www/wasm/gs /var/www/wasm/cpdf mkdir -p ./wasm/pymupdf ./wasm/gs ./wasm/cpdf
tar xzf bentopdf-pymupdf-wasm-0.11.14.tgz -C /var/www/wasm/pymupdf --strip-components=1 tar xzf bentopdf-pymupdf-wasm-0.11.14.tgz -C ./wasm/pymupdf --strip-components=1
tar xzf bentopdf-gs-wasm-*.tgz -C /var/www/wasm/gs --strip-components=1 tar xzf bentopdf-gs-wasm-*.tgz -C ./wasm/gs --strip-components=1
tar xzf coherentpdf-*.tgz -C /var/www/wasm/cpdf --strip-components=1 tar xzf coherentpdf-*.tgz -C ./wasm/cpdf --strip-components=1
# Serve the WASM files on your internal web server (e.g., nginx, Apache)
# Make sure they're accessible at the URLs you configured in Step 2
# Run BentoPDF # Run BentoPDF
docker run -d -p 3000:8080 --restart unless-stopped bentopdf docker run -d -p 3000:8080 --restart unless-stopped bentopdf
``` ```
Users open their browser, access BentoPDF on the internal network, and the browser fetches WASM files from the internal server. No internet required at any point. Make sure the WASM files are accessible at the URLs you configured in Step 2.
</details>
> [!NOTE] > [!NOTE]
> If you're building from source instead of Docker, set the variables in `.env.production` before running `npm run build`: > If you're building from source instead of Docker, set the variables in `.env.production` before running `npm run build`:
@@ -584,6 +650,14 @@ docker run -p 3000:8080 bentopdf
# The app will be accessible at http://localhost:3000/bentopdf/ # The app will be accessible at http://localhost:3000/bentopdf/
``` ```
**Default Language:**
Set the default UI language at build time. Users can still switch languages — this only changes the initial default. Supported: `en`, `ar`, `be`, `fr`, `de`, `es`, `zh`, `zh-TW`, `vi`, `tr`, `id`, `it`, `pt`, `nl`, `da`.
```bash
docker build --build-arg VITE_DEFAULT_LANGUAGE=fr -t bentopdf .
```
**Combined with Simple Mode:** **Combined with Simple Mode:**
```bash ```bash
@@ -676,6 +750,42 @@ For organizations that want a clean, distraction-free interface focused solely o
For more details, see [SIMPLE_MODE.md](SIMPLE_MODE.md). For more details, see [SIMPLE_MODE.md](SIMPLE_MODE.md).
### 🎨 Custom Branding
Replace the default BentoPDF logo, name, and footer text with your own. Branding is configured via environment variables at **build time** and works across all deployment methods (Docker, static hosting, air-gapped VMs).
| Variable | Description | Default |
| ------------------ | --------------------------------------- | --------------------------------------- |
| `VITE_BRAND_NAME` | Brand name shown in header and footer | `BentoPDF` |
| `VITE_BRAND_LOGO` | Path to logo file relative to `public/` | `images/favicon-no-bg.svg` |
| `VITE_FOOTER_TEXT` | Custom footer/copyright text | `© 2026 BentoPDF. All rights reserved.` |
**Docker:**
```bash
docker build \
--build-arg VITE_BRAND_NAME="AcmePDF" \
--build-arg VITE_BRAND_LOGO="images/acme-logo.svg" \
--build-arg VITE_FOOTER_TEXT="© 2026 Acme Corp. Internal use only." \
-t acmepdf .
```
**Building from source:**
Place your logo in the `public/` folder, then build:
```bash
VITE_BRAND_NAME="AcmePDF" \
VITE_BRAND_LOGO="images/acme-logo.svg" \
VITE_FOOTER_TEXT="© 2026 Acme Corp. Internal use only." \
npm run build
```
Or set the values in `.env.production` before building.
> [!TIP]
> Branding works in both full mode and Simple Mode. You can combine it with other build-time options like `SIMPLE_MODE`, `BASE_URL`, and `VITE_DEFAULT_LANGUAGE`.
### 🔒 Security Features ### 🔒 Security Features
BentoPDF runs as a non-root user using nginx-unprivileged for enhanced security: BentoPDF runs as a non-root user using nginx-unprivileged for enhanced security:
@@ -691,11 +801,31 @@ docker build -t bentopdf .
docker run -p 8080:8080 bentopdf docker run -p 8080:8080 bentopdf
``` ```
#### Custom User ID (PUID/PGID)
For environments that require running as a specific non-root user (e.g., NAS devices, Kubernetes with security contexts), use the non-root Dockerfile:
```bash
# Build the non-root image
docker build -f Dockerfile.nonroot -t bentopdf-nonroot .
# Run with custom UID/GID
docker run -d -p 3000:8080 -e PUID=1000 -e PGID=1000 bentopdf-nonroot
```
| Variable | Description | Default |
| -------- | ------------------ | ------- |
| `PUID` | User ID to run as | `1000` |
| `PGID` | Group ID to run as | `1000` |
> [!NOTE]
> The standard `Dockerfile` uses `nginx-unprivileged` (UID 101) and is recommended for most deployments. Use `Dockerfile.nonroot` only when you need a specific UID/GID.
For detailed security configuration, see [SECURITY.md](SECURITY.md). For detailed security configuration, see [SECURITY.md](SECURITY.md).
### Digital Signature CORS Proxy (Required) ### Digital Signature CORS Proxy (Required)
The **Digital Signature** tool uses a signing library that may need to fetch certificate chain data from certificate authority provider. Since many certificate servers don't include CORS headers, a proxy is required for this feature to work in the browser. The **Digital Signature** tool uses a signing library that may need to fetch certificate chain data from certificate authority providers. Since many certificate servers don't include CORS headers (and often serve over HTTP, which is blocked by browsers on HTTPS sites), a proxy is required for this feature to work.
**When is the proxy needed?** **When is the proxy needed?**
@@ -717,30 +847,48 @@ The **Digital Signature** tool uses a signing library that may need to fetch cer
npx wrangler login npx wrangler login
``` ```
3. **Deploy the worker:** 3. **Update allowed origins** — open `cors-proxy-worker.js` and change `ALLOWED_ORIGINS` to your domain:
```js
const ALLOWED_ORIGINS = [
'https://your-domain.com',
'https://www.your-domain.com',
];
```
> [!IMPORTANT]
> Without this step, the proxy will reject all requests from your site with a 403 error. The default only allows `bentopdf.com`.
4. **Deploy the worker:**
```bash ```bash
npx wrangler deploy npx wrangler deploy
``` ```
4. **Note your worker URL** (e.g., `https://bentopdf-cors-proxy.your-subdomain.workers.dev`) 5. **Note your worker URL** (e.g., `https://bentopdf-cors-proxy.your-subdomain.workers.dev`)
5. **Set the environment variable when building:** 6. **Set the environment variable when building:**
```bash ```bash
VITE_CORS_PROXY_URL=https://your-worker-url.workers.dev npm run build VITE_CORS_PROXY_URL=https://your-worker-url.workers.dev npm run build
``` ```
Or with Docker:
```bash
docker build --build-arg VITE_CORS_PROXY_URL="https://your-worker-url.workers.dev" -t your-bentopdf .
```
#### Production Security Features #### Production Security Features
The CORS proxy includes several security measures: The CORS proxy includes several security measures:
| Feature | Description | | Feature | Description |
| ----------------------- | ------------------------------------------------------------------------- | | ----------------------- | -------------------------------------------------------------------------------------- |
| **URL Restrictions** | Only allows certificate URLs (`.crt`, `.cer`, `.pem`, `/certs/`, `/ocsp`) | | **Origin Validation** | Only allows requests from domains listed in `ALLOWED_ORIGINS` |
| **Private IP Blocking** | Blocks requests to localhost, 10.x, 192.168.x, 172.16-31.x | | **URL Restrictions** | Only allows certificate URLs (`.crt`, `.cer`, `.pem`, `/certs/`, `/ocsp`, `/crl`) |
| **File Size Limit** | Rejects files larger than 10MB | | **Private IP Blocking** | Blocks IPv4/IPv6 private ranges, link-local, loopback, decimal IPs, and cloud metadata |
| **Rate Limiting** | 60 requests per IP per minute (requires KV) | | **Content-Type Safety** | Only returns safe certificate MIME types, blocks upstream content-type injection |
| **HMAC Signatures** | Optional client-side signing (limited protection) | | **File Size Limit** | Streams response with 10MB limit, aborts mid-download if exceeded |
| **Rate Limiting** | 60 requests per IP per minute (requires KV) |
| **HMAC Signatures** | Optional client-side signing (deters casual abuse) |
#### Enabling Rate Limiting (Recommended) #### Enabling Rate Limiting (Recommended)
+2 -2
View File
@@ -7,10 +7,10 @@ type: application
# This is the chart version. This version number should be incremented each time you make changes # This is the chart version. This version number should be incremented each time you make changes
# to the chart and its templates, including the app version. # to the chart and its templates, including the app version.
# Versions are expected to follow Semantic Versioning (https://semver.org/) # Versions are expected to follow Semantic Versioning (https://semver.org/)
version: 1.0.0 version: 1.0.2
# This is the version number of the application being deployed. This version number should be # This is the version number of the application being deployed. This version number should be
# incremented each time you make changes to the application. Versions are not expected to # incremented each time you make changes to the application. Versions are not expected to
# follow Semantic Versioning. They should reflect the version the application is using. # follow Semantic Versioning. They should reflect the version the application is using.
# It is recommended to use it with quotes. # It is recommended to use it with quotes.
appVersion: "2.1.0" appVersion: '2.2.1'
+2 -2
View File
@@ -30,7 +30,7 @@ ingress:
```yaml ```yaml
gateway: gateway:
enabled: true enabled: true
gatewayClassName: "cloudflare" # or your gateway class gatewayClassName: 'cloudflare' # or your gateway class
httpRoute: httpRoute:
enabled: true enabled: true
@@ -74,7 +74,7 @@ httpRoute:
```yaml ```yaml
env: env:
- name: DISABLE_IPV6 - name: DISABLE_IPV6
value: "true" value: 'true'
``` ```
## Publish this chart to GHCR (OCI) for testing/deploying ## Publish this chart to GHCR (OCI) for testing/deploying
+5 -5
View File
@@ -2,7 +2,7 @@
{{- if .Values.httpRoute.enabled }} {{- if .Values.httpRoute.enabled }}
{{- $defaultGatewayName := .Values.gateway.name -}} {{- $defaultGatewayName := .Values.gateway.name -}}
{{- if not $defaultGatewayName -}} {{- if not $defaultGatewayName -}}
{{- $defaultGatewayName = printf "%s-gateway" (include "chart.fullname" .) -}} {{- $defaultGatewayName = printf "%s-gateway" (include "bentopdf.name" .) -}}
{{- end -}} {{- end -}}
{{- $defaultGatewayNs := .Release.Namespace -}} {{- $defaultGatewayNs := .Release.Namespace -}}
{{- if .Values.gateway.enabled -}} {{- if .Values.gateway.enabled -}}
@@ -31,16 +31,16 @@
{{- end }} {{- end }}
{{- end }} {{- end }}
{{- else if contains "NodePort" .Values.service.type }} {{- else if contains "NodePort" .Values.service.type }}
export NODE_PORT=$(kubectl get --namespace {{ .Release.Namespace }} -o jsonpath="{.spec.ports[0].nodePort}" services {{ include "chart.fullname" . }}) export NODE_PORT=$(kubectl get --namespace {{ .Release.Namespace }} -o jsonpath="{.spec.ports[0].nodePort}" services {{ include "bentopdf.name" . }})
export NODE_IP=$(kubectl get nodes --namespace {{ .Release.Namespace }} -o jsonpath="{.items[0].status.addresses[0].address}") export NODE_IP=$(kubectl get nodes --namespace {{ .Release.Namespace }} -o jsonpath="{.items[0].status.addresses[0].address}")
echo http://$NODE_IP:$NODE_PORT echo http://$NODE_IP:$NODE_PORT
{{- else if contains "LoadBalancer" .Values.service.type }} {{- else if contains "LoadBalancer" .Values.service.type }}
NOTE: It may take a few minutes for the LoadBalancer IP to be available. NOTE: It may take a few minutes for the LoadBalancer IP to be available.
You can watch its status by running 'kubectl get --namespace {{ .Release.Namespace }} svc -w {{ include "chart.fullname" . }}' You can watch its status by running 'kubectl get --namespace {{ .Release.Namespace }} svc -w {{ include "bentopdf.name" . }}'
export SERVICE_IP=$(kubectl get svc --namespace {{ .Release.Namespace }} {{ include "chart.fullname" . }} --template "{{"{{ range (index .status.loadBalancer.ingress 0) }}{{.}}{{ end }}"}}") export SERVICE_IP=$(kubectl get svc --namespace {{ .Release.Namespace }} {{ include "bentopdf.name" . }} --template "{{"{{ range (index .status.loadBalancer.ingress 0) }}{{.}}{{ end }}"}}")
echo http://$SERVICE_IP:{{ .Values.service.port }} echo http://$SERVICE_IP:{{ .Values.service.port }}
{{- else if contains "ClusterIP" .Values.service.type }} {{- else if contains "ClusterIP" .Values.service.type }}
export POD_NAME=$(kubectl get pods --namespace {{ .Release.Namespace }} -l "app.kubernetes.io/name={{ include "chart.name" . }},app.kubernetes.io/instance={{ .Release.Name }}" -o jsonpath="{.items[0].metadata.name}") export POD_NAME=$(kubectl get pods --namespace {{ .Release.Namespace }} -l "app.kubernetes.io/name={{ include "bentopdf.name" . }},app.kubernetes.io/instance={{ .Release.Name }}" -o jsonpath="{.items[0].metadata.name}")
export CONTAINER_PORT=$(kubectl get pod --namespace {{ .Release.Namespace }} $POD_NAME -o jsonpath="{.spec.containers[0].ports[0].containerPort}") export CONTAINER_PORT=$(kubectl get pod --namespace {{ .Release.Namespace }} $POD_NAME -o jsonpath="{.spec.containers[0].ports[0].containerPort}")
echo "Visit http://127.0.0.1:8080 to use your application" echo "Visit http://127.0.0.1:8080 to use your application"
kubectl --namespace {{ .Release.Namespace }} port-forward $POD_NAME 8080:$CONTAINER_PORT kubectl --namespace {{ .Release.Namespace }} port-forward $POD_NAME 8080:$CONTAINER_PORT
+8 -27
View File
@@ -1,41 +1,23 @@
{{/* {{/*
Expand the name of the chart. Expand the name of the bentopdf
*/}} */}}
{{- define "chart.name" -}} {{- define "bentopdf.name" -}}
{{- default .Chart.Name .Values.nameOverride | trunc 63 | trimSuffix "-" }} {{- default .Chart.Name .Values.nameOverride | trunc 63 | trimSuffix "-" }}
{{- end }} {{- end }}
{{/*
Create a default fully qualified app name.
We truncate at 63 chars because some Kubernetes name fields are limited to this (by the DNS naming spec).
If release name contains chart name it will be used as a full name.
*/}}
{{- define "chart.fullname" -}}
{{- if .Values.fullnameOverride }}
{{- .Values.fullnameOverride | trunc 63 | trimSuffix "-" }}
{{- else }}
{{- $name := default .Chart.Name .Values.nameOverride }}
{{- if contains $name .Release.Name }}
{{- .Release.Name | trunc 63 | trimSuffix "-" }}
{{- else }}
{{- printf "%s-%s" .Release.Name $name | trunc 63 | trimSuffix "-" }}
{{- end }}
{{- end }}
{{- end }}
{{/* {{/*
Create chart name and version as used by the chart label. Create chart name and version as used by the chart label.
*/}} */}}
{{- define "chart.chart" -}} {{- define "bentopdf.chart" -}}
{{- printf "%s-%s" .Chart.Name .Chart.Version | replace "+" "_" | trunc 63 | trimSuffix "-" }} {{- printf "%s-%s" .Chart.Name .Chart.Version | replace "+" "_" | trunc 63 | trimSuffix "-" }}
{{- end }} {{- end }}
{{/* {{/*
Common labels Common labels
*/}} */}}
{{- define "chart.labels" -}} {{- define "bentopdf.labels" -}}
helm.sh/chart: {{ include "chart.chart" . }} helm.sh/chart: {{ include "bentopdf.chart" . }}
{{ include "chart.selectorLabels" . }} {{ include "bentopdf.selectorLabels" . }}
{{- if .Chart.AppVersion }} {{- if .Chart.AppVersion }}
app.kubernetes.io/version: {{ .Chart.AppVersion | quote }} app.kubernetes.io/version: {{ .Chart.AppVersion | quote }}
{{- end }} {{- end }}
@@ -45,8 +27,7 @@ app.kubernetes.io/managed-by: {{ .Release.Service }}
{{/* {{/*
Selector labels Selector labels
*/}} */}}
{{- define "chart.selectorLabels" -}} {{- define "bentopdf.selectorLabels" -}}
app.kubernetes.io/name: {{ include "chart.name" . }} app.kubernetes.io/name: {{ include "bentopdf.name" . }}
app.kubernetes.io/instance: {{ .Release.Name }} app.kubernetes.io/instance: {{ .Release.Name }}
{{- end }} {{- end }}
+5 -4
View File
@@ -1,14 +1,15 @@
apiVersion: apps/v1 apiVersion: apps/v1
kind: Deployment kind: Deployment
metadata: metadata:
name: {{ include "chart.fullname" . }} name: {{ include "bentopdf.name" . }}
namespace: {{ .Release.Namespace }}
labels: labels:
{{- include "chart.labels" . | nindent 4 }} {{- include "bentopdf.labels" . | nindent 4 }}
spec: spec:
replicas: {{ .Values.replicaCount }} replicas: {{ .Values.replicaCount }}
selector: selector:
matchLabels: matchLabels:
{{- include "chart.selectorLabels" . | nindent 6 }} {{- include "bentopdf.selectorLabels" . | nindent 6 }}
template: template:
metadata: metadata:
{{- with .Values.podAnnotations }} {{- with .Values.podAnnotations }}
@@ -16,7 +17,7 @@ spec:
{{- toYaml . | nindent 8 }} {{- toYaml . | nindent 8 }}
{{- end }} {{- end }}
labels: labels:
{{- include "chart.labels" . | nindent 8 }} {{- include "bentopdf.labels" . | nindent 8 }}
{{- with .Values.podLabels }} {{- with .Values.podLabels }}
{{- toYaml . | nindent 8 }} {{- toYaml . | nindent 8 }}
{{- end }} {{- end }}
+2 -2
View File
@@ -1,7 +1,7 @@
{{- if .Values.gateway.enabled -}} {{- if .Values.gateway.enabled -}}
{{- $gatewayName := .Values.gateway.name -}} {{- $gatewayName := .Values.gateway.name -}}
{{- if not $gatewayName -}} {{- if not $gatewayName -}}
{{- $gatewayName = printf "%s-gateway" (include "chart.fullname" .) -}} {{- $gatewayName = printf "%s-gateway" (include "bentopdf.name" .) -}}
{{- end -}} {{- end -}}
{{- $gatewayNs := default .Release.Namespace .Values.gateway.namespace -}} {{- $gatewayNs := default .Release.Namespace .Values.gateway.namespace -}}
apiVersion: gateway.networking.k8s.io/v1 apiVersion: gateway.networking.k8s.io/v1
@@ -10,7 +10,7 @@ metadata:
name: {{ $gatewayName }} name: {{ $gatewayName }}
namespace: {{ $gatewayNs }} namespace: {{ $gatewayNs }}
labels: labels:
{{- include "chart.labels" . | nindent 4 }} {{- include "bentopdf.labels" . | nindent 4 }}
{{- with .Values.gateway.annotations }} {{- with .Values.gateway.annotations }}
annotations: annotations:
{{- toYaml . | nindent 4 }} {{- toYaml . | nindent 4 }}
+11 -25
View File
@@ -1,20 +1,11 @@
{{- if .Values.httpRoute.enabled -}} {{- if .Values.httpRoute.enabled -}}
{{- $fullName := include "chart.fullname" . -}}
{{- $svcPort := .Values.service.port -}}
{{- $defaultGatewayName := .Values.gateway.name -}}
{{- if not $defaultGatewayName -}}
{{- $defaultGatewayName = printf "%s-gateway" $fullName -}}
{{- end -}}
{{- $defaultGatewayNs := .Release.Namespace -}}
{{- if .Values.gateway.enabled -}}
{{- $defaultGatewayNs = default .Release.Namespace .Values.gateway.namespace -}}
{{- end -}}
apiVersion: gateway.networking.k8s.io/v1 apiVersion: gateway.networking.k8s.io/v1
kind: HTTPRoute kind: HTTPRoute
metadata: metadata:
name: {{ $fullName }} name: {{ include "bentopdf.name" . }}
namespace: {{ .Release.Namespace }}
labels: labels:
{{- include "chart.labels" . | nindent 4 }} {{- include "bentopdf.labels" . | nindent 4 }}
{{- with .Values.httpRoute.annotations }} {{- with .Values.httpRoute.annotations }}
annotations: annotations:
{{- toYaml . | nindent 4 }} {{- toYaml . | nindent 4 }}
@@ -23,14 +14,9 @@ spec:
parentRefs: parentRefs:
{{- if .Values.httpRoute.parentRefs }} {{- if .Values.httpRoute.parentRefs }}
{{- range $ref := .Values.httpRoute.parentRefs }} {{- range $ref := .Values.httpRoute.parentRefs }}
- name: {{ default $defaultGatewayName $ref.name | quote }} - name: {{ $.Values.gateway.name | default $ref.name | quote }}
{{- $refNs := $ref.namespace | default $defaultGatewayNs -}} namespace: {{ $ref.namespace | default (default $.Release.Namespace $.Values.gateway.namespace) | quote }}
namespace: {{ $refNs | quote }} sectionName: {{ $ref.sectionName | default "http" | quote }}
{{- with $ref.sectionName }}
sectionName: {{ . | quote }}
{{- else }}
sectionName: "http"
{{- end }}
{{- with $ref.kind }} {{- with $ref.kind }}
kind: {{ . | quote }} kind: {{ . | quote }}
{{- end }} {{- end }}
@@ -40,8 +26,8 @@ spec:
{{- end }} {{- end }}
{{- else }} {{- else }}
# Default parentRef when parentRefs is empty or not set # Default parentRef when parentRefs is empty or not set
- name: {{ $defaultGatewayName | quote }} - name: {{ .Values.gateway.name | default (printf "%s-gateway" (include "bentopdf.name" $)) | quote }}
namespace: {{ $defaultGatewayNs | quote }} namespace: {{ .Values.gateway.namespace | default .Release.Namespace | quote }}
sectionName: "http" sectionName: "http"
{{- end }} {{- end }}
{{- with .Values.httpRoute.hostnames }} {{- with .Values.httpRoute.hostnames }}
@@ -52,15 +38,15 @@ spec:
{{- range .Values.httpRoute.rules }} {{- range .Values.httpRoute.rules }}
- {{- with .matches }} - {{- with .matches }}
matches: matches:
{{- toYaml . | nindent 8 }} {{- toYaml . | nindent 8 }}
{{- end }} {{- end }}
{{- with .filters }} {{- with .filters }}
filters: filters:
{{- toYaml . | nindent 8 }} {{- toYaml . | nindent 8 }}
{{- end }} {{- end }}
backendRefs: backendRefs:
- name: {{ $fullName }} - name: {{ include "bentopdf.name" $ }}
port: {{ $svcPort }} port: {{ $.Values.service.port }}
weight: 1 weight: 1
{{- end }} {{- end }}
{{- end }} {{- end }}
+4 -3
View File
@@ -2,9 +2,10 @@
apiVersion: networking.k8s.io/v1 apiVersion: networking.k8s.io/v1
kind: Ingress kind: Ingress
metadata: metadata:
name: {{ include "chart.fullname" . }} name: {{ include "bentopdf.name" . }}
namespace: {{ .Release.Namespace }}
labels: labels:
{{- include "chart.labels" . | nindent 4 }} {{- include "bentopdf.labels" . | nindent 4 }}
{{- with .Values.ingress.annotations }} {{- with .Values.ingress.annotations }}
annotations: annotations:
{{- toYaml . | nindent 4 }} {{- toYaml . | nindent 4 }}
@@ -35,7 +36,7 @@ spec:
{{- end }} {{- end }}
backend: backend:
service: service:
name: {{ include "chart.fullname" $ }} name: {{ include "bentopdf.name" $ }}
port: port:
number: {{ $.Values.service.port }} number: {{ $.Values.service.port }}
{{- end }} {{- end }}
+4 -3
View File
@@ -1,9 +1,10 @@
apiVersion: v1 apiVersion: v1
kind: Service kind: Service
metadata: metadata:
name: {{ include "chart.fullname" . }} name: {{ include "bentopdf.name" . }}
namespace: {{ .Release.Namespace }}
labels: labels:
{{- include "chart.labels" . | nindent 4 }} {{- include "bentopdf.labels" . | nindent 4 }}
spec: spec:
type: {{ .Values.service.type }} type: {{ .Values.service.type }}
ports: ports:
@@ -12,4 +13,4 @@ spec:
protocol: TCP protocol: TCP
name: http name: http
selector: selector:
{{- include "chart.selectorLabels" . | nindent 4 }} {{- include "bentopdf.selectorLabels" . | nindent 4 }}
-4
View File
@@ -1,4 +0,0 @@
# The service account was removed, as it's not necessary for a chart like this.
#
# There are no calls to the Kubernetes API - routing between services.
# It's a simple nginx static website deployment only.
+3 -3
View File
@@ -1,9 +1,9 @@
apiVersion: v1 apiVersion: v1
kind: Pod kind: Pod
metadata: metadata:
name: "{{ include "chart.fullname" . }}-test-connection" name: "{{ include "bentopdf.name" . }}-test-connection"
labels: labels:
{{- include "chart.labels" . | nindent 4 }} {{- include "bentopdf.labels" . | nindent 4 }}
annotations: annotations:
"helm.sh/hook": test "helm.sh/hook": test
spec: spec:
@@ -11,5 +11,5 @@ spec:
- name: wget - name: wget
image: busybox image: busybox
command: ['wget'] command: ['wget']
args: ['-qO-', 'http://{{ include "chart.fullname" . }}:{{ .Values.service.port }}/'] args: ['-qO-', 'http://{{ include "bentopdf.name" . }}:{{ .Values.service.port }}/']
restartPolicy: Never restartPolicy: Never
+6 -7
View File
@@ -5,11 +5,10 @@ image:
# Image built from this repo's `Dockerfile` (nginx serving static frontend). # Image built from this repo's `Dockerfile` (nginx serving static frontend).
repository: ghcr.io/alam00000/bentopdf-simple repository: ghcr.io/alam00000/bentopdf-simple
pullPolicy: IfNotPresent pullPolicy: IfNotPresent
tag: "" tag: ''
imagePullSecrets: [] imagePullSecrets: []
nameOverride: "" nameOverride: ''
fullnameOverride: ""
podAnnotations: {} podAnnotations: {}
podLabels: {} podLabels: {}
@@ -25,7 +24,7 @@ env: []
ingress: ingress:
enabled: false enabled: false
className: "" className: ''
annotations: {} annotations: {}
hosts: hosts:
- host: bentopdf.local - host: bentopdf.local
@@ -37,10 +36,10 @@ ingress:
# Gateway API (optional) # Gateway API (optional)
gateway: gateway:
enabled: false enabled: false
name: "" # default: release fullname + "-gateway" suffix name: '' # default: release name + "-gateway" suffix
namespace: "" # to override the namespace namespace: '' # to override the namespace
annotations: {} annotations: {}
gatewayClassName: "" # required when enabled=true gatewayClassName: '' # required when enabled=true
listeners: listeners:
- name: http - name: http
protocol: HTTP protocol: HTTP
+1 -1
View File
@@ -28,7 +28,7 @@ npx wrangler secret put CPDF_SOURCE -c wasm-wrangler.toml
**Recommended Source URLs:** **Recommended Source URLs:**
- PYMUPDF_SOURCE: `https://cdn.jsdelivr.net/npm/@bentopdf/[email protected]4/` - PYMUPDF_SOURCE: `https://cdn.jsdelivr.net/npm/@bentopdf/[email protected]6/`
- GS_SOURCE: `https://cdn.jsdelivr.net/npm/@bentopdf/gs-wasm/assets/` - GS_SOURCE: `https://cdn.jsdelivr.net/npm/@bentopdf/gs-wasm/assets/`
- CPDF_SOURCE: `https://cdn.jsdelivr.net/npm/coherentpdf/dist/` - CPDF_SOURCE: `https://cdn.jsdelivr.net/npm/coherentpdf/dist/`
+392 -288
View File
@@ -12,28 +12,27 @@
* - PROXY_SECRET: Shared secret for HMAC signature verification * - PROXY_SECRET: Shared secret for HMAC signature verification
*/ */
const ALLOWED_PATTERNS = [ const ALLOWED_PATH_PATTERNS = [
/\.crt$/i, /\.crt$/i,
/\.cer$/i, /\.cer$/i,
/\.pem$/i, /\.pem$/i,
/\/certs\//i, /\/certs\//i,
/\/ocsp/i, /\/ocsp/i,
/\/crl/i, /\/crl/i,
/caIssuers/i, /caIssuers/i,
]; ];
const ALLOWED_ORIGINS = [ const ALLOWED_ORIGINS = ['https://www.bentopdf.com', 'https://bentopdf.com'];
'https://www.bentopdf.com',
'https://bentopdf.com',
];
const BLOCKED_DOMAINS = [ const SAFE_CONTENT_TYPES = [
'localhost', 'application/x-x509-ca-cert',
'127.0.0.1', 'application/pkix-cert',
'0.0.0.0', 'application/x-pem-file',
'application/pkcs7-mime',
'application/octet-stream',
'text/plain',
]; ];
const MAX_TIMESTAMP_AGE_MS = 5 * 60 * 1000; const MAX_TIMESTAMP_AGE_MS = 5 * 60 * 1000;
const RATE_LIMIT_MAX_REQUESTS = 60; const RATE_LIMIT_MAX_REQUESTS = 60;
@@ -42,310 +41,415 @@ const RATE_LIMIT_WINDOW_MS = 60 * 1000;
const MAX_FILE_SIZE_BYTES = 10 * 1024 * 1024; const MAX_FILE_SIZE_BYTES = 10 * 1024 * 1024;
async function verifySignature(message, signature, secret) { async function verifySignature(message, signature, secret) {
try { try {
const encoder = new TextEncoder();
const key = await crypto.subtle.importKey(
'raw',
encoder.encode(secret),
{ name: 'HMAC', hash: 'SHA-256' },
false,
['verify']
);
const signatureBytes = new Uint8Array(
signature.match(/.{1,2}/g).map(byte => parseInt(byte, 16))
);
return await crypto.subtle.verify(
'HMAC',
key,
signatureBytes,
encoder.encode(message)
);
} catch (e) {
console.error('Signature verification error:', e);
return false;
}
}
async function generateSignature(message, secret) {
const encoder = new TextEncoder(); const encoder = new TextEncoder();
const key = await crypto.subtle.importKey( const key = await crypto.subtle.importKey(
'raw', 'raw',
encoder.encode(secret), encoder.encode(secret),
{ name: 'HMAC', hash: 'SHA-256' }, { name: 'HMAC', hash: 'SHA-256' },
false, false,
['sign'] ['verify']
); );
const signature = await crypto.subtle.sign( const signatureBytes = new Uint8Array(
'HMAC', signature.match(/.{1,2}/g).map((byte) => parseInt(byte, 16))
key,
encoder.encode(message)
); );
return Array.from(new Uint8Array(signature)) return await crypto.subtle.verify(
.map(b => b.toString(16).padStart(2, '0')) 'HMAC',
.join(''); key,
signatureBytes,
encoder.encode(message)
);
} catch (e) {
console.error('Signature verification error:', e);
return false;
}
} }
function isAllowedOrigin(origin) { function isAllowedOrigin(origin) {
if (!origin) return false; if (!origin) return false;
return ALLOWED_ORIGINS.some(allowed => origin.startsWith(allowed.replace(/\/$/, ''))); return ALLOWED_ORIGINS.includes(origin);
}
function isPrivateOrReservedHost(hostname) {
if (
/^10\./.test(hostname) ||
/^172\.(1[6-9]|2[0-9]|3[0-1])\./.test(hostname) ||
/^192\.168\./.test(hostname) ||
/^169\.254\./.test(hostname) || // link-local (cloud metadata)
/^100\.(6[4-9]|[7-9]\d|1[0-1]\d|12[0-7])\./.test(hostname) || // CGNAT
/^127\./.test(hostname) ||
/^0\./.test(hostname)
) {
return true;
}
if (/^\d+$/.test(hostname)) {
return true;
}
const lower = hostname.toLowerCase().replace(/^\[|\]$/g, '');
if (
lower === '::1' ||
lower.startsWith('::ffff:') ||
lower.startsWith('fe80') ||
lower.startsWith('fc') ||
lower.startsWith('fd') ||
lower.startsWith('ff')
) {
return true;
}
const blockedNames = [
'localhost',
'localhost.localdomain',
'0.0.0.0',
'[::1]',
];
if (blockedNames.includes(hostname.toLowerCase())) {
return true;
}
return false;
} }
function isValidCertificateUrl(urlString) { function isValidCertificateUrl(urlString) {
try { try {
const url = new URL(urlString); const url = new URL(urlString);
if (!['http:', 'https:'].includes(url.protocol)) { if (!['http:', 'https:'].includes(url.protocol)) {
return false; return false;
}
if (BLOCKED_DOMAINS.some(domain => url.hostname.includes(domain))) {
return false;
}
const hostname = url.hostname;
if (/^10\./.test(hostname) ||
/^172\.(1[6-9]|2[0-9]|3[0-1])\./.test(hostname) ||
/^192\.168\./.test(hostname)) {
return false;
}
return ALLOWED_PATTERNS.some(pattern => pattern.test(urlString));
} catch {
return false;
} }
if (isPrivateOrReservedHost(url.hostname)) {
return false;
}
return ALLOWED_PATH_PATTERNS.some((pattern) => pattern.test(url.pathname));
} catch {
return false;
}
}
function getSafeContentType(upstreamContentType) {
if (!upstreamContentType) return 'application/octet-stream';
const match = SAFE_CONTENT_TYPES.find((ct) =>
upstreamContentType.startsWith(ct)
);
return match || 'application/octet-stream';
} }
function corsHeaders(origin) { function corsHeaders(origin) {
return { return {
'Access-Control-Allow-Origin': origin || '*', 'Access-Control-Allow-Origin': origin,
'Access-Control-Allow-Methods': 'GET, OPTIONS', 'Access-Control-Allow-Methods': 'GET, OPTIONS',
'Access-Control-Allow-Headers': 'Content-Type', 'Access-Control-Allow-Headers': 'Content-Type',
'Access-Control-Max-Age': '86400', 'Access-Control-Max-Age': '86400',
}; };
} }
function handleOptions(request) { function handleOptions(request) {
const origin = request.headers.get('Origin'); const origin = request.headers.get('Origin');
return new Response(null, { if (!isAllowedOrigin(origin)) {
status: 204, return new Response(null, { status: 403 });
headers: corsHeaders(origin), }
}); return new Response(null, {
status: 204,
headers: corsHeaders(origin),
});
} }
export default { export default {
async fetch(request, env, ctx) { async fetch(request, env, ctx) {
const url = new URL(request.url); const url = new URL(request.url);
const origin = request.headers.get('Origin'); const origin = request.headers.get('Origin');
if (request.method === 'OPTIONS') { if (request.method === 'OPTIONS') {
return handleOptions(request); return handleOptions(request);
}
// NOTE: If you are selfhosting this proxy, you can remove this check, or can set it to only accept requests from your own domain
if (!isAllowedOrigin(origin)) {
return new Response(
JSON.stringify({
error: 'Forbidden',
message: 'This proxy only accepts requests from allowed origins',
}),
{
status: 403,
headers: {
'Content-Type': 'application/json',
},
} }
);
}
// NOTE: If you are selfhosting this proxy, you can remove this check, or can set it to only accept requests from your own domain if (request.method !== 'GET') {
if (!isAllowedOrigin(origin)) { return new Response('Method not allowed', {
return new Response(JSON.stringify({ status: 405,
error: 'Forbidden', headers: corsHeaders(origin),
message: 'This proxy only accepts requests from bentopdf.com', });
}), { }
status: 403,
headers: { const targetUrl = url.searchParams.get('url');
'Content-Type': 'application/json',
}, if (!targetUrl) {
}); return new Response(
JSON.stringify({
error: 'Missing url parameter',
usage: 'GET /?url=<certificate_url>',
}),
{
status: 400,
headers: {
...corsHeaders(origin),
'Content-Type': 'application/json',
},
} }
);
}
if (request.method !== 'GET') { if (!isValidCertificateUrl(targetUrl)) {
return new Response('Method not allowed', { return new Response(
status: 405, JSON.stringify({
headers: corsHeaders(origin), error: 'Invalid or disallowed URL',
}); message:
'Only certificate-related URLs are allowed (*.crt, *.cer, *.pem, /certs/, /ocsp, /crl)',
}),
{
status: 403,
headers: {
...corsHeaders(origin),
'Content-Type': 'application/json',
},
} }
);
}
const targetUrl = url.searchParams.get('url'); if (env.PROXY_SECRET) {
const timestamp = url.searchParams.get('t'); const timestamp = url.searchParams.get('t');
const signature = url.searchParams.get('sig'); const signature = url.searchParams.get('sig');
if (env.PROXY_SECRET) { if (!timestamp || !signature) {
if (!timestamp || !signature) { return new Response(
return new Response(JSON.stringify({ JSON.stringify({
error: 'Missing authentication parameters', error: 'Missing authentication parameters',
message: 'Request must include timestamp (t) and signature (sig) parameters', message:
}), { 'Request must include timestamp (t) and signature (sig) parameters',
status: 401, }),
headers: { {
...corsHeaders(origin), status: 401,
'Content-Type': 'application/json', headers: {
}, ...corsHeaders(origin),
}); 'Content-Type': 'application/json',
} },
}
const requestTime = parseInt(timestamp, 10); );
const now = Date.now(); }
if (isNaN(requestTime) || Math.abs(now - requestTime) > MAX_TIMESTAMP_AGE_MS) {
return new Response(JSON.stringify({ const requestTime = parseInt(timestamp, 10);
error: 'Request expired or invalid timestamp', const now = Date.now();
message: 'Timestamp must be within 5 minutes of current time', if (
}), { isNaN(requestTime) ||
status: 401, Math.abs(now - requestTime) > MAX_TIMESTAMP_AGE_MS
headers: { ) {
...corsHeaders(origin), return new Response(
'Content-Type': 'application/json', JSON.stringify({
}, error: 'Request expired or invalid timestamp',
}); message: 'Timestamp must be within 5 minutes of current time',
} }),
{
const message = `${targetUrl}${timestamp}`; status: 401,
const isValid = await verifySignature(message, signature, env.PROXY_SECRET); headers: {
...corsHeaders(origin),
if (!isValid) { 'Content-Type': 'application/json',
return new Response(JSON.stringify({ },
error: 'Invalid signature', }
message: 'Request signature verification failed', );
}), { }
status: 401,
headers: { const message = `${targetUrl}${timestamp}`;
...corsHeaders(origin), const isValid = await verifySignature(
'Content-Type': 'application/json', message,
}, signature,
}); env.PROXY_SECRET
);
if (!isValid) {
return new Response(
JSON.stringify({
error: 'Invalid signature',
message: 'Request signature verification failed',
}),
{
status: 401,
headers: {
...corsHeaders(origin),
'Content-Type': 'application/json',
},
}
);
}
}
const clientIP = request.headers.get('CF-Connecting-IP') || 'unknown';
const rateLimitKey = `ratelimit:${clientIP}`;
const now = Date.now();
if (env.RATE_LIMIT_KV) {
const rateLimitData = await env.RATE_LIMIT_KV.get(rateLimitKey, {
type: 'json',
});
const requests = rateLimitData?.requests || [];
const recentRequests = requests.filter(
(t) => now - t < RATE_LIMIT_WINDOW_MS
);
if (recentRequests.length >= RATE_LIMIT_MAX_REQUESTS) {
return new Response(
JSON.stringify({
error: 'Rate limit exceeded',
message: `Maximum ${RATE_LIMIT_MAX_REQUESTS} requests per minute. Please try again later.`,
retryAfter: Math.ceil(
(recentRequests[0] + RATE_LIMIT_WINDOW_MS - now) / 1000
),
}),
{
status: 429,
headers: {
...corsHeaders(origin),
'Content-Type': 'application/json',
'Retry-After': Math.ceil(
(recentRequests[0] + RATE_LIMIT_WINDOW_MS - now) / 1000
).toString(),
},
}
);
}
recentRequests.push(now);
await env.RATE_LIMIT_KV.put(
rateLimitKey,
JSON.stringify({ requests: recentRequests }),
{
expirationTtl: 120,
}
);
} else {
console.warn(
'[CORS Proxy] RATE_LIMIT_KV not configured — rate limiting is disabled'
);
}
try {
const response = await fetch(targetUrl, {
headers: {
'User-Agent': 'BentoPDF-CertProxy/1.0',
},
});
if (!response.ok) {
return new Response(
JSON.stringify({
error: 'Failed to fetch certificate',
status: response.status,
}),
{
status: response.status,
headers: {
...corsHeaders(origin),
'Content-Type': 'application/json',
},
}
);
}
// Check Content-Length header first (fast reject for known-large responses)
const contentLength = parseInt(
response.headers.get('Content-Length') || '0',
10
);
if (contentLength > MAX_FILE_SIZE_BYTES) {
return new Response(
JSON.stringify({
error: 'File too large',
message: `Certificate file exceeds maximum size of ${MAX_FILE_SIZE_BYTES / 1024}KB`,
}),
{
status: 413,
headers: {
...corsHeaders(origin),
'Content-Type': 'application/json',
},
}
);
}
const reader = response.body.getReader();
const chunks = [];
let totalSize = 0;
while (true) {
const { done, value } = await reader.read();
if (done) break;
totalSize += value.byteLength;
if (totalSize > MAX_FILE_SIZE_BYTES) {
reader.cancel();
return new Response(
JSON.stringify({
error: 'File too large',
message: `Certificate file exceeds maximum size of ${MAX_FILE_SIZE_BYTES / 1024}KB`,
}),
{
status: 413,
headers: {
...corsHeaders(origin),
'Content-Type': 'application/json',
},
} }
);
} }
if (!targetUrl) { chunks.push(value);
return new Response(JSON.stringify({ }
error: 'Missing url parameter',
usage: 'GET /?url=<certificate_url>', const certData = new Uint8Array(totalSize);
}), { let offset = 0;
status: 400, for (const chunk of chunks) {
headers: { certData.set(chunk, offset);
...corsHeaders(origin), offset += chunk.byteLength;
'Content-Type': 'application/json', }
},
}); return new Response(certData, {
status: 200,
headers: {
...corsHeaders(origin),
'Content-Type': getSafeContentType(
response.headers.get('Content-Type')
),
'Content-Length': totalSize.toString(),
'Cache-Control': 'public, max-age=86400',
'X-Content-Type-Options': 'nosniff',
},
});
} catch (error) {
console.error('Proxy fetch error:', error);
return new Response(
JSON.stringify({
error: 'Proxy error',
message: 'Failed to fetch the requested certificate',
}),
{
status: 500,
headers: {
...corsHeaders(origin),
'Content-Type': 'application/json',
},
} }
);
if (!isValidCertificateUrl(targetUrl)) { }
return new Response(JSON.stringify({ },
error: 'Invalid or disallowed URL',
message: 'Only certificate-related URLs are allowed (*.crt, *.cer, *.pem, /certs/, /ocsp, /crl)',
}), {
status: 403,
headers: {
...corsHeaders(origin),
'Content-Type': 'application/json',
},
});
}
const clientIP = request.headers.get('CF-Connecting-IP') || 'unknown';
const rateLimitKey = `ratelimit:${clientIP}`;
const now = Date.now();
if (env.RATE_LIMIT_KV) {
const rateLimitData = await env.RATE_LIMIT_KV.get(rateLimitKey, { type: 'json' });
const requests = rateLimitData?.requests || [];
const recentRequests = requests.filter(t => now - t < RATE_LIMIT_WINDOW_MS);
if (recentRequests.length >= RATE_LIMIT_MAX_REQUESTS) {
return new Response(JSON.stringify({
error: 'Rate limit exceeded',
message: `Maximum ${RATE_LIMIT_MAX_REQUESTS} requests per minute. Please try again later.`,
retryAfter: Math.ceil((recentRequests[0] + RATE_LIMIT_WINDOW_MS - now) / 1000),
}), {
status: 429,
headers: {
...corsHeaders(origin),
'Content-Type': 'application/json',
'Retry-After': Math.ceil((recentRequests[0] + RATE_LIMIT_WINDOW_MS - now) / 1000).toString(),
},
});
}
recentRequests.push(now);
await env.RATE_LIMIT_KV.put(rateLimitKey, JSON.stringify({ requests: recentRequests }), {
expirationTtl: 120,
});
}
try {
const response = await fetch(targetUrl, {
headers: {
'User-Agent': 'BentoPDF-CertProxy/1.0',
},
});
if (!response.ok) {
return new Response(JSON.stringify({
error: 'Failed to fetch certificate',
status: response.status,
statusText: response.statusText,
}), {
status: response.status,
headers: {
...corsHeaders(origin),
'Content-Type': 'application/json',
},
});
}
const contentLength = parseInt(response.headers.get('Content-Length') || '0', 10);
if (contentLength > MAX_FILE_SIZE_BYTES) {
return new Response(JSON.stringify({
error: 'File too large',
message: `Certificate file exceeds maximum size of ${MAX_FILE_SIZE_BYTES / 1024}KB`,
size: contentLength,
maxSize: MAX_FILE_SIZE_BYTES,
}), {
status: 413,
headers: {
...corsHeaders(origin),
'Content-Type': 'application/json',
},
});
}
const certData = await response.arrayBuffer();
if (certData.byteLength > MAX_FILE_SIZE_BYTES) {
return new Response(JSON.stringify({
error: 'File too large',
message: `Certificate file exceeds maximum size of ${MAX_FILE_SIZE_BYTES / 1024}KB`,
size: certData.byteLength,
maxSize: MAX_FILE_SIZE_BYTES,
}), {
status: 413,
headers: {
...corsHeaders(origin),
'Content-Type': 'application/json',
},
});
}
return new Response(certData, {
status: 200,
headers: {
...corsHeaders(origin),
'Content-Type': response.headers.get('Content-Type') || 'application/x-x509-ca-cert',
'Content-Length': certData.byteLength.toString(),
'Cache-Control': 'public, max-age=86400',
},
});
} catch (error) {
return new Response(JSON.stringify({
error: 'Proxy error',
message: error.message,
}), {
status: 500,
headers: {
...corsHeaders(origin),
'Content-Type': 'application/json',
},
});
}
},
}; };
+48
View File
@@ -17,6 +17,12 @@ npm install
npm run build npm run build
``` ```
To customize branding, set environment variables before building:
```bash
VITE_BRAND_NAME="AcmePDF" VITE_BRAND_LOGO="images/acme-logo.svg" npm run build
```
## Step 2: Copy Files ## Step 2: Copy Files
```bash ```bash
@@ -52,6 +58,9 @@ Create `/etc/apache2/sites-available/bentopdf.conf`:
# WASM MIME type # WASM MIME type
AddType application/wasm .wasm AddType application/wasm .wasm
# Prevent double-compression of pre-compressed files
SetEnvIfNoCase Request_URI "\.gz$" no-gzip
# Compression # Compression
<IfModule mod_deflate.c> <IfModule mod_deflate.c>
AddOutputFilterByType DEFLATE text/html text/plain text/css application/javascript application/json application/wasm AddOutputFilterByType DEFLATE text/html text/plain text/css application/javascript application/json application/wasm
@@ -67,9 +76,24 @@ Create `/etc/apache2/sites-available/bentopdf.conf`:
ExpiresByType image/svg+xml "access plus 1 year" ExpiresByType image/svg+xml "access plus 1 year"
</IfModule> </IfModule>
# Required headers for SharedArrayBuffer (LibreOffice WASM)
Header always set Cross-Origin-Embedder-Policy "require-corp"
Header always set Cross-Origin-Opener-Policy "same-origin"
Header always set Cross-Origin-Resource-Policy "cross-origin"
# Security headers # Security headers
Header always set X-Frame-Options "SAMEORIGIN" Header always set X-Frame-Options "SAMEORIGIN"
Header always set X-Content-Type-Options "nosniff" Header always set X-Content-Type-Options "nosniff"
# Pre-compressed LibreOffice WASM files
<FilesMatch "soffice\.wasm\.gz$">
ForceType application/wasm
Header set Content-Encoding "gzip"
</FilesMatch>
<FilesMatch "soffice\.data\.gz$">
ForceType application/octet-stream
Header set Content-Encoding "gzip"
</FilesMatch>
</VirtualHost> </VirtualHost>
``` ```
@@ -191,6 +215,30 @@ Check that mod_rewrite is enabled:
sudo a2enmod rewrite sudo a2enmod rewrite
``` ```
### Word/ODT/Excel to PDF Not Working
LibreOffice WASM requires `SharedArrayBuffer`, which needs these headers:
```apache
Header always set Cross-Origin-Embedder-Policy "require-corp"
Header always set Cross-Origin-Opener-Policy "same-origin"
```
The pre-compressed `.wasm.gz` and `.data.gz` files also need correct `Content-Encoding`:
```apache
<FilesMatch "soffice\.wasm\.gz$">
ForceType application/wasm
Header set Content-Encoding "gzip"
</FilesMatch>
<FilesMatch "soffice\.data\.gz$">
ForceType application/octet-stream
Header set Content-Encoding "gzip"
</FilesMatch>
```
Ensure `mod_headers` is enabled: `sudo a2enmod headers`
### Permission Denied ### Permission Denied
```bash ```bash
+63 -6
View File
@@ -23,7 +23,8 @@ aws s3 website s3://your-bentopdf-bucket \
## Step 2: Build and Upload ## Step 2: Build and Upload
```bash ```bash
# Build the project # Build the project (optionally with custom branding)
# VITE_BRAND_NAME="AcmePDF" VITE_BRAND_LOGO="images/acme-logo.svg" npm run build
npm run build npm run build
# Sync to S3 # Sync to S3
@@ -56,6 +57,62 @@ Or use the AWS Console:
4. Default root object: `index.html` 4. Default root object: `index.html`
5. Create distribution 5. Create distribution
## Step 3b: Response Headers Policy (Required for LibreOffice WASM)
LibreOffice-based conversions (Word, Excel, PowerPoint to PDF) require `SharedArrayBuffer`, which needs specific response headers. Create a CloudFront Response Headers Policy:
1. Go to CloudFront → Policies → Response headers
2. Create a custom policy with these headers:
| Header | Value |
| ------------------------------ | -------------- |
| `Cross-Origin-Embedder-Policy` | `require-corp` |
| `Cross-Origin-Opener-Policy` | `same-origin` |
| `Cross-Origin-Resource-Policy` | `cross-origin` |
3. Attach the policy to your distribution's default cache behavior
Or via CLI:
```bash
aws cloudfront create-response-headers-policy \
--response-headers-policy-config '{
"Name": "BentoPDF-COEP-COOP",
"CustomHeadersConfig": {
"Quantity": 3,
"Items": [
{"Header": "Cross-Origin-Embedder-Policy", "Value": "require-corp", "Override": true},
{"Header": "Cross-Origin-Opener-Policy", "Value": "same-origin", "Override": true},
{"Header": "Cross-Origin-Resource-Policy", "Value": "cross-origin", "Override": true}
]
}
}'
```
## Step 3c: S3 Metadata for Pre-Compressed WASM Files
The LibreOffice WASM files are pre-compressed (`.wasm.gz`, `.data.gz`). Set the correct Content-Type and Content-Encoding so browsers decompress them:
```bash
# Set correct headers for soffice.wasm.gz
aws s3 cp s3://your-bentopdf-bucket/libreoffice-wasm/soffice.wasm.gz \
s3://your-bentopdf-bucket/libreoffice-wasm/soffice.wasm.gz \
--content-type "application/wasm" \
--content-encoding "gzip" \
--metadata-directive REPLACE
# Set correct headers for soffice.data.gz
aws s3 cp s3://your-bentopdf-bucket/libreoffice-wasm/soffice.data.gz \
s3://your-bentopdf-bucket/libreoffice-wasm/soffice.data.gz \
--content-type "application/octet-stream" \
--content-encoding "gzip" \
--metadata-directive REPLACE
```
::: warning Important
Without the response headers policy, `SharedArrayBuffer` is unavailable and LibreOffice WASM conversions will hang at ~55%. Without the correct Content-Encoding on the `.gz` files, the browser receives raw gzip bytes and WASM compilation fails.
:::
## Step 4: S3 Bucket Policy ## Step 4: S3 Bucket Policy
Allow CloudFront to access the bucket: Allow CloudFront to access the bucket:
@@ -94,11 +151,11 @@ Configure 404 to return `index.html` for SPA routing:
## Cost Estimation ## Cost Estimation
| Resource | Estimated Cost | | Resource | Estimated Cost |
|----------|----------------| | -------------------------- | -------------- |
| S3 Storage (~500MB) | ~$0.01/month | | S3 Storage (~500MB) | ~$0.01/month |
| CloudFront (1TB transfer) | ~$85/month | | CloudFront (1TB transfer) | ~$85/month |
| CloudFront (10GB transfer) | ~$0.85/month | | CloudFront (10GB transfer) | ~$0.85/month |
::: tip ::: tip
Use S3 Intelligent Tiering for cost optimization on infrequently accessed files. Use S3 Intelligent Tiering for cost optimization on infrequently accessed files.
+41 -15
View File
@@ -10,27 +10,49 @@
## Build Configuration ## Build Configuration
| Setting | Value | | Setting | Value |
|---------|-------| | ---------------------- | --------------- |
| Framework preset | None | | Framework preset | None |
| Build command | `npm run build` | | Build command | `npm run build` |
| Build output directory | `dist` | | Build output directory | `dist` |
| Root directory | `/` | | Root directory | `/` |
## Environment Variables ## Environment Variables
Add these in Settings → Environment variables: Add these in Settings → Environment variables:
| Variable | Value | | Variable | Value |
|----------|-------| | ----------------------- | ------------------------------------------ |
| `NODE_VERSION` | `18` | | `NODE_VERSION` | `18` |
| `SIMPLE_MODE` | `false` (optional) | | `SIMPLE_MODE` | `false` (optional) |
| `VITE_BRAND_NAME` | Custom brand name (optional) |
| `VITE_BRAND_LOGO` | Logo path relative to `public/` (optional) |
| `VITE_FOOTER_TEXT` | Custom footer/copyright text (optional) |
| `VITE_DEFAULT_LANGUAGE` | Default UI language, e.g. `fr` (optional) |
## Configuration File ## Configuration File
Create `_headers` in your `public` folder: Create `_headers` in your `public` folder:
``` ```
# Required security headers for SharedArrayBuffer (used by LibreOffice WASM)
/*
Cross-Origin-Embedder-Policy: require-corp
Cross-Origin-Opener-Policy: same-origin
Cross-Origin-Resource-Policy: cross-origin
# Pre-compressed LibreOffice WASM binary
/libreoffice-wasm/soffice.wasm.gz
Content-Type: application/wasm
Content-Encoding: gzip
Cache-Control: public, max-age=31536000, immutable
# Pre-compressed LibreOffice WASM data
/libreoffice-wasm/soffice.data.gz
Content-Type: application/octet-stream
Content-Encoding: gzip
Cache-Control: public, max-age=31536000, immutable
# Cache WASM files aggressively # Cache WASM files aggressively
/*.wasm /*.wasm
Cache-Control: public, max-age=31536000, immutable Cache-Control: public, max-age=31536000, immutable
@@ -41,6 +63,10 @@ Create `_headers` in your `public` folder:
Cache-Control: no-cache Cache-Control: no-cache
``` ```
::: warning Important
The `Cross-Origin-Embedder-Policy` and `Cross-Origin-Opener-Policy` headers are required for Word/ODT/Excel/PowerPoint to PDF conversions. Without them, `SharedArrayBuffer` is unavailable and the LibreOffice WASM engine will fail to initialize.
:::
Create `_redirects` for SPA routing: Create `_redirects` for SPA routing:
``` ```
@@ -89,11 +115,11 @@ npx wrangler deploy
### Security Features ### Security Features
| Feature | Description | | Feature | Description |
|---------|-------------| | ----------------------- | ------------------------------ |
| **URL Restrictions** | Only certificate URLs allowed | | **URL Restrictions** | Only certificate URLs allowed |
| **File Size Limit** | Max 10MB per request | | **File Size Limit** | Max 10MB per request |
| **Rate Limiting** | 60 req/IP/min (requires KV) | | **Rate Limiting** | 60 req/IP/min (requires KV) |
| **Private IP Blocking** | Blocks localhost, internal IPs | | **Private IP Blocking** | Blocks localhost, internal IPs |
### Enable Rate Limiting ### Enable Rate Limiting
+79 -24
View File
@@ -2,6 +2,8 @@
The digital signature tool uses a CORS proxy to fetch issuer certificates from external Certificate Authorities (CAs). This is necessary because many CA servers don't include CORS headers in their responses, which prevents direct browser-based fetching. The digital signature tool uses a CORS proxy to fetch issuer certificates from external Certificate Authorities (CAs). This is necessary because many CA servers don't include CORS headers in their responses, which prevents direct browser-based fetching.
Additionally, many CA servers serve certificates over plain HTTP. When your BentoPDF instance is hosted over HTTPS, browsers block these HTTP requests (mixed content policy). The CORS proxy resolves both issues by routing requests through an HTTPS endpoint with proper headers.
## How It Works ## How It Works
When signing a PDF with a certificate: When signing a PDF with a certificate:
@@ -13,35 +15,64 @@ When signing a PDF with a certificate:
## Self-Hosting the CORS Proxy ## Self-Hosting the CORS Proxy
If you're self-hosting BentoPDF, you'll need to deploy your own CORS proxy. If you're self-hosting BentoPDF, you'll need to deploy your own CORS proxy for digital signatures to work with certificates that require chain fetching.
### Option 1: Cloudflare Workers (Recommended) ### Option 1: Cloudflare Workers (Recommended)
1. **Install Wrangler CLI**: 1. **Install Wrangler CLI**:
```bash ```bash
npm install -g wrangler npm install -g wrangler
``` ```
2. **Login to Cloudflare**: 2. **Login to Cloudflare**:
```bash ```bash
wrangler login wrangler login
``` ```
3. **Deploy the proxy**: 3. **Clone BentoPDF and update allowed origins**:
```bash
git clone https://github.com/alam00000/bentopdf.git
cd bentopdf/cloudflare
```
Open `cors-proxy-worker.js` and change the `ALLOWED_ORIGINS` array to your domain:
```js
const ALLOWED_ORIGINS = [
'https://your-domain.com',
'https://www.your-domain.com',
];
```
::: warning Important
Without this change, the proxy will reject all requests from your site with a **403 Forbidden** error. The default only allows requests from `bentopdf.com`.
:::
4. **Deploy the proxy**:
```bash ```bash
cd cloudflare
wrangler deploy wrangler deploy
``` ```
4. **Update your environment**: Note your worker URL (e.g., `https://bentopdf-cors-proxy.your-subdomain.workers.dev`).
Create a `.env` or set in your hosting platform:
``` 5. **Rebuild BentoPDF with the proxy URL**:
VITE_CORS_PROXY_URL=https://your-worker-name.your-subdomain.workers.dev
If using Docker:
```bash
docker build \
--build-arg VITE_CORS_PROXY_URL="https://your-worker.workers.dev" \
-t your-bentopdf .
``` ```
5. **Rebuild BentoPDF**: If building from source:
```bash ```bash
npm run build VITE_CORS_PROXY_URL=https://your-worker.workers.dev npm run build
``` ```
### Option 2: Custom Backend Proxy ### Option 2: Custom Backend Proxy
@@ -51,9 +82,9 @@ You can also create your own proxy endpoint. The requirements are:
1. Accept GET requests with a `url` query parameter 1. Accept GET requests with a `url` query parameter
2. Fetch the URL from your server (no CORS restrictions server-side) 2. Fetch the URL from your server (no CORS restrictions server-side)
3. Return the response with these headers: 3. Return the response with these headers:
- `Access-Control-Allow-Origin: *` (or your specific origin) - `Access-Control-Allow-Origin: https://your-domain.com`
- `Access-Control-Allow-Methods: GET, OPTIONS` - `Access-Control-Allow-Methods: GET, OPTIONS`
- `Content-Type: application/x-x509-ca-cert` - `X-Content-Type-Options: nosniff`
Example Express.js implementation: Example Express.js implementation:
@@ -70,8 +101,9 @@ app.get('/api/cert-proxy', async (req, res) => {
const response = await fetch(targetUrl); const response = await fetch(targetUrl);
const data = await response.arrayBuffer(); const data = await response.arrayBuffer();
res.set('Access-Control-Allow-Origin', '*'); res.set('Access-Control-Allow-Origin', 'https://your-domain.com');
res.set('Content-Type', 'application/x-x509-ca-cert'); res.set('Content-Type', 'application/octet-stream');
res.set('X-Content-Type-Options', 'nosniff');
res.send(Buffer.from(data)); res.send(Buffer.from(data));
} catch (error) { } catch (error) {
res.status(500).json({ error: 'Proxy error' }); res.status(500).json({ error: 'Proxy error' });
@@ -83,9 +115,15 @@ app.get('/api/cert-proxy', async (req, res) => {
The included Cloudflare Worker has several security measures: The included Cloudflare Worker has several security measures:
- **URL Validation**: Only allows certificate-related URLs (`.crt`, `.cer`, `.pem`, `/certs/`, `/ocsp`, `/crl`) | Feature | Description |
- **Blocked Domains**: Prevents access to localhost and private IP ranges | ----------------------- | -------------------------------------------------------------------------------------- |
- **HTTP Methods**: Only allows GET requests | **Origin Validation** | Only allows requests from domains listed in `ALLOWED_ORIGINS` |
| **URL Restrictions** | Only allows certificate URLs (`.crt`, `.cer`, `.pem`, `/certs/`, `/ocsp`, `/crl`) |
| **Private IP Blocking** | Blocks IPv4/IPv6 private ranges, link-local, loopback, decimal IPs, and cloud metadata |
| **Content-Type Safety** | Only returns safe certificate MIME types, blocks upstream content-type injection |
| **File Size Limit** | Streams response with 10MB limit, aborts mid-download if exceeded |
| **Rate Limiting** | 60 requests per IP per minute (requires KV) |
| **HMAC Signatures** | Optional client-side signing (deters casual abuse) |
## Disabling the Proxy ## Disabling the Proxy
@@ -95,22 +133,39 @@ If you don't want to use a CORS proxy, set the environment variable to an empty
VITE_CORS_PROXY_URL= VITE_CORS_PROXY_URL=
``` ```
**Note**: Without the proxy, signing with certificates that require external chain fetching (like FNMT or some corporate CAs) will fail. **Note**: Without the proxy, signing with certificates that require external chain fetching (like FNMT or some corporate CAs) will fail with a "Failed to fetch" error.
## Troubleshooting ## Troubleshooting
### "Failed to fetch certificate chain" Error ### "Signing error: TypeError: Failed to fetch"
1. Check that your CORS proxy is deployed and accessible This usually means either:
2. Verify the `VITE_CORS_PROXY_URL` is correctly set
3. Test the proxy directly: 1. **No CORS proxy configured** — Set `VITE_CORS_PROXY_URL` and rebuild
```bash 2. **Mixed content blocked** — Your site is HTTPS but the certificate's issuer URL is HTTP. The CORS proxy resolves this.
curl "https://your-proxy.workers.dev?url=https://www.cert.fnmt.es/certs/ACUSU.crt" 3. **CORS proxy rejecting your origin** — Check that your domain is in the `ALLOWED_ORIGINS` array in `cors-proxy-worker.js`
```
### "403 Forbidden" from the proxy
Your domain is not in the `ALLOWED_ORIGINS` list. Edit `cors-proxy-worker.js`:
```js
const ALLOWED_ORIGINS = ['https://your-domain.com'];
```
Then redeploy: `npx wrangler deploy`
### Testing the proxy
```bash
curl -H "Origin: https://your-domain.com" \
"https://your-proxy.workers.dev?url=http://www.cert.fnmt.es/certs/ACUSU.crt"
```
### Certificates That Work Without Proxy ### Certificates That Work Without Proxy
Some certificates include the full chain in the P12/PFX file and don't require external fetching: Some certificates include the full chain in the P12/PFX file and don't require external fetching:
- Self-signed certificates - Self-signed certificates
- Some commercial CAs that bundle intermediate certificates - Some commercial CAs that bundle intermediate certificates
- Certificates you've manually assembled with the full chain - Certificates you've manually assembled with the full chain
+79 -5
View File
@@ -92,21 +92,40 @@ 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_BRAND_NAME` | Custom brand name | `BentoPDF` |
| `VITE_BRAND_LOGO` | Logo path relative to `public/` | `images/favicon-no-bg.svg` |
| `VITE_FOOTER_TEXT` | Custom footer/copyright text | `© 2026 BentoPDF. All rights reserved.` |
WASM module URLs are pre-configured with CDN defaults — all advanced features work out of the box. Override these for air-gapped or self-hosted deployments. WASM module URLs are pre-configured with CDN defaults — all advanced features work out of the box. Override these for air-gapped or self-hosted deployments.
`VITE_DEFAULT_LANGUAGE` sets the UI language for first-time visitors. Supported values: `en`, `ar`, `be`, `fr`, `de`, `es`, `zh`, `zh-TW`, `vi`, `tr`, `id`, `it`, `pt`, `nl`, `da`. Users can still switch languages — this only changes the default.
Example: Example:
```bash ```bash
docker run -d \ # Build with French as the default language
-e SIMPLE_MODE=true \ docker build --build-arg VITE_DEFAULT_LANGUAGE=fr -t bentopdf .
-p 3000:8080 \ docker run -d -p 3000:8080 bentopdf
ghcr.io/alam00000/bentopdf:latest
``` ```
### Custom Branding
Replace the default BentoPDF logo, name, and footer text with your own. Place your logo file in the `public/` folder (or use an existing image), then pass the branding variables at build time:
```bash
docker build \
--build-arg VITE_BRAND_NAME="AcmePDF" \
--build-arg VITE_BRAND_LOGO="images/acme-logo.svg" \
--build-arg VITE_FOOTER_TEXT="© 2026 Acme Corp. Internal use only." \
-t acmepdf .
```
Branding works in both full mode and Simple Mode, and can be combined with all other build-time options.
### Custom WASM URLs (Air-Gapped / Self-Hosted) ### Custom WASM URLs (Air-Gapped / Self-Hosted)
> [!IMPORTANT] > [!IMPORTANT]
@@ -147,6 +166,61 @@ docker run -d -p 3000:8080 --restart unless-stopped bentopdf
Set a variable to empty string to disable that module (users must configure manually via Advanced Settings). Set a variable to empty string to disable that module (users must configure manually via Advanced Settings).
## Custom User ID (PUID/PGID)
For environments that require running as a specific non-root user (NAS devices, Kubernetes with security contexts, organizational policies), BentoPDF provides a separate Dockerfile with LSIO-style PUID/PGID support.
### Build and Run
```bash
# Build the non-root image
docker build -f Dockerfile.nonroot -t bentopdf-nonroot .
# Run with custom UID/GID
docker run -d \
--name bentopdf \
-p 3000:8080 \
-e PUID=1000 \
-e PGID=1000 \
--restart unless-stopped \
bentopdf-nonroot
```
### Environment Variables
| Variable | Description | Default |
| -------------- | --------------------- | ------- |
| `PUID` | User ID to run as | `1000` |
| `PGID` | Group ID to run as | `1000` |
| `DISABLE_IPV6` | Disable IPv6 listener | `false` |
### Docker Compose
```yaml
services:
bentopdf:
build:
context: .
dockerfile: Dockerfile.nonroot
container_name: bentopdf
ports:
- '3000:8080'
environment:
- PUID=1000
- PGID=1000
restart: unless-stopped
```
### How It Works
The container starts as root, creates a user with the specified PUID/PGID, adjusts ownership on all writable directories, then drops privileges using `su-exec`. The nginx process runs entirely as your specified user.
> [!NOTE]
> The standard `Dockerfile` uses `nginx-unprivileged` (UID 101) and is recommended for most deployments. Use `Dockerfile.nonroot` only when you need a specific UID/GID.
> [!WARNING]
> PUID/PGID cannot be `0` (root). The entrypoint validates inputs and will exit with an error for invalid values.
## With Traefik (Reverse Proxy) ## With Traefik (Reverse Proxy)
```yaml ```yaml
+99 -7
View File
@@ -103,6 +103,36 @@ Deploy to a subdirectory:
BASE_URL=/pdf-tools/ npm run build BASE_URL=/pdf-tools/ npm run build
``` ```
### Custom Branding
Replace the default BentoPDF logo, name, and footer text with your own at build time:
| Variable | Description | Default |
| ------------------ | ------------------------------------- | --------------------------------------- |
| `VITE_BRAND_NAME` | Brand name shown in header and footer | `BentoPDF` |
| `VITE_BRAND_LOGO` | Logo path relative to `public/` | `images/favicon-no-bg.svg` |
| `VITE_FOOTER_TEXT` | Custom footer/copyright text | `© 2026 BentoPDF. All rights reserved.` |
```bash
# Place your logo in public/, then build
VITE_BRAND_NAME="AcmePDF" \
VITE_BRAND_LOGO="images/acme-logo.svg" \
VITE_FOOTER_TEXT="© 2026 Acme Corp. Internal use only." \
npm run build
```
Or via Docker:
```bash
docker build \
--build-arg VITE_BRAND_NAME="AcmePDF" \
--build-arg VITE_BRAND_LOGO="images/acme-logo.svg" \
--build-arg VITE_FOOTER_TEXT="© 2026 Acme Corp. Internal use only." \
-t acmepdf .
```
Branding works in both full mode and Simple Mode, and can be combined with all other build-time options (`BASE_URL`, `SIMPLE_MODE`, `VITE_DEFAULT_LANGUAGE`).
## Deployment Guides ## Deployment Guides
Choose your platform: Choose your platform:
@@ -137,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/
``` ```
@@ -166,6 +196,66 @@ Users can also override these defaults at any time via **Advanced Settings** in
For networks with no internet access (government, healthcare, financial, etc.). The WASM URLs are baked into the JavaScript at **build time** — the actual WASM files are downloaded by the **user's browser** at runtime. So you need to prepare everything on a machine with internet, then transfer it into the isolated network. For networks with no internet access (government, healthcare, financial, etc.). The WASM URLs are baked into the JavaScript at **build time** — the actual WASM files are downloaded by the **user's browser** at runtime. So you need to prepare everything on a machine with internet, then transfer it into the isolated network.
#### Automated Script (Recommended)
The included `prepare-airgap.sh` script automates the entire process — downloading WASM packages, building the Docker image, and producing a self-contained bundle with a setup script.
```bash
git clone https://github.com/alam00000/bentopdf.git
cd bentopdf
# Interactive mode — prompts for all options
bash scripts/prepare-airgap.sh
# Or fully automated
bash scripts/prepare-airgap.sh --wasm-base-url https://internal.example.com/wasm
```
This produces a bundle directory:
```
bentopdf-airgap-bundle/
bentopdf.tar # Docker image
*.tgz # WASM packages (PyMuPDF, Ghostscript, CoherentPDF)
setup.sh # Setup script for the air-gapped side
README.md # Instructions
```
Transfer the bundle into the air-gapped network via USB, internal artifact repo, or approved method. Then run the included setup script:
```bash
cd bentopdf-airgap-bundle
bash setup.sh
```
The setup script loads the Docker image, extracts WASM files, and optionally starts the container.
**Script options:**
| Flag | Description | Default |
| ----------------------- | ------------------------------------------------ | --------------------------------- |
| `--wasm-base-url <url>` | Where WASMs will be hosted internally | _(required, prompted if missing)_ |
| `--image-name <name>` | Docker image tag | `bentopdf` |
| `--output-dir <path>` | Output bundle directory | `./bentopdf-airgap-bundle` |
| `--simple-mode` | Enable Simple Mode | off |
| `--base-url <path>` | Subdirectory base URL (e.g. `/pdf/`) | `/` |
| `--language <code>` | Default UI language (e.g. `fr`, `de`) | _(none)_ |
| `--brand-name <name>` | Custom brand name | _(none)_ |
| `--brand-logo <path>` | Logo path relative to `public/` | _(none)_ |
| `--footer-text <text>` | Custom footer text | _(none)_ |
| `--dockerfile <path>` | Dockerfile to use | `Dockerfile` |
| `--skip-docker` | Skip Docker build and export | off |
| `--skip-wasm` | Skip WASM download (reuse existing `.tgz` files) | off |
::: warning Same-Origin Requirement
WASM files must be served from the **same origin** as the BentoPDF app. Web Workers use `importScripts()` which cannot load scripts cross-origin. For example, if BentoPDF runs at `https://internal.example.com`, the WASM base URL should also be `https://internal.example.com/wasm`.
:::
#### Manual Steps
<details>
<summary>If you prefer to do it manually without the script</summary>
**Step 1: Download the WASM packages** (on a machine with internet) **Step 1: Download the WASM packages** (on a machine with internet)
```bash ```bash
@@ -206,17 +296,19 @@ Copy via USB, internal artifact repo, or approved transfer method:
# Load the Docker image # Load the Docker image
docker load -i bentopdf.tar docker load -i bentopdf.tar
# Extract WASM packages to your internal web server's document root # Extract WASM packages
mkdir -p /var/www/wasm/pymupdf /var/www/wasm/gs /var/www/wasm/cpdf mkdir -p ./wasm/pymupdf ./wasm/gs ./wasm/cpdf
tar xzf bentopdf-pymupdf-wasm-0.11.14.tgz -C /var/www/wasm/pymupdf --strip-components=1 tar xzf bentopdf-pymupdf-wasm-0.11.14.tgz -C ./wasm/pymupdf --strip-components=1
tar xzf bentopdf-gs-wasm-*.tgz -C /var/www/wasm/gs --strip-components=1 tar xzf bentopdf-gs-wasm-*.tgz -C ./wasm/gs --strip-components=1
tar xzf coherentpdf-*.tgz -C /var/www/wasm/cpdf --strip-components=1 tar xzf coherentpdf-*.tgz -C ./wasm/cpdf --strip-components=1
# Run BentoPDF # Run BentoPDF
docker run -d -p 3000:8080 --restart unless-stopped bentopdf docker run -d -p 3000:8080 --restart unless-stopped bentopdf
``` ```
Make sure the WASM files are accessible at the URLs you configured in Step 2. Users open their browser and everything works — no internet required. Make sure the WASM files are accessible at the URLs you configured in Step 2.
</details>
::: info Building from source instead of Docker? ::: info Building from source instead of Docker?
Set the variables in `.env.production` before running `npm run build`: Set the variables in `.env.production` before running `npm run build`:
+61 -14
View File
@@ -17,11 +17,11 @@
### Step 2: Configure Build Settings ### Step 2: Configure Build Settings
| Setting | Value | | Setting | Value |
|---------|-------| | ----------------- | --------------- |
| Build command | `npm run build` | | Build command | `npm run build` |
| Publish directory | `dist` | | Publish directory | `dist` |
| Node version | 18+ | | Node version | 18+ |
### Step 3: Deploy ### Step 3: Deploy
@@ -39,27 +39,61 @@ Create `netlify.toml` in your project root:
[build.environment] [build.environment]
NODE_VERSION = "18" NODE_VERSION = "18"
# SPA routing # Required security headers for SharedArrayBuffer (used by LibreOffice WASM)
[[redirects]] [[headers]]
from = "/*" for = "/*"
to = "/index.html" [headers.values]
status = 200 Cross-Origin-Embedder-Policy = "require-corp"
Cross-Origin-Opener-Policy = "same-origin"
Cross-Origin-Resource-Policy = "cross-origin"
# Cache WASM files # Pre-compressed LibreOffice WASM binary - must be served with Content-Encoding
[[headers]]
for = "/libreoffice-wasm/soffice.wasm.gz"
[headers.values]
Content-Type = "application/wasm"
Content-Encoding = "gzip"
Cache-Control = "public, max-age=31536000, immutable"
# Pre-compressed LibreOffice WASM data - must be served with Content-Encoding
[[headers]]
for = "/libreoffice-wasm/soffice.data.gz"
[headers.values]
Content-Type = "application/octet-stream"
Content-Encoding = "gzip"
Cache-Control = "public, max-age=31536000, immutable"
# Cache other WASM files
[[headers]] [[headers]]
for = "*.wasm" for = "*.wasm"
[headers.values] [headers.values]
Cache-Control = "public, max-age=31536000, immutable" Cache-Control = "public, max-age=31536000, immutable"
Content-Type = "application/wasm" Content-Type = "application/wasm"
# SPA routing
[[redirects]]
from = "/*"
to = "/index.html"
status = 200
``` ```
::: warning Important
The `Cross-Origin-Embedder-Policy` and `Cross-Origin-Opener-Policy` headers are required for Word/ODT/Excel/PowerPoint to PDF conversions. Without them, `SharedArrayBuffer` is unavailable and the LibreOffice WASM engine will fail to initialize.
The `Content-Encoding: gzip` headers on the `.wasm.gz` and `.data.gz` files tell the browser to decompress them automatically. Without these, the browser receives raw gzip bytes and WASM compilation fails.
:::
## Environment Variables ## Environment Variables
Set these in Site settings → Environment variables: Set these in Site settings → Environment variables:
| Variable | Description | | Variable | Description |
|----------|-------------| | ----------------------- | ----------------------------------------------------------- |
| `SIMPLE_MODE` | Set to `true` for minimal build | | `SIMPLE_MODE` | Set to `true` for minimal build |
| `VITE_BRAND_NAME` | Custom brand name (replaces "BentoPDF") |
| `VITE_BRAND_LOGO` | Logo path relative to `public/` (e.g. `images/my-logo.svg`) |
| `VITE_FOOTER_TEXT` | Custom footer/copyright text |
| `VITE_DEFAULT_LANGUAGE` | Default UI language (e.g. `fr`, `de`, `es`) |
## Custom Domain ## Custom Domain
@@ -78,6 +112,19 @@ git lfs track "*.wasm"
## Troubleshooting ## Troubleshooting
### Word/ODT/Excel to PDF Stuck at 55%
If document conversions hang at 55%, open DevTools Console and check:
```js
console.log(window.crossOriginIsolated); // should be true
console.log(typeof SharedArrayBuffer); // should be "function"
```
If `crossOriginIsolated` is `false`, the COEP/COOP headers from your `netlify.toml` are not being applied. Make sure the file is in your project root and redeploy.
If you see `expected magic word 00 61 73 6d, found 1f 8b 08 08` in the console, the `.wasm.gz` files are missing `Content-Encoding: gzip` headers. Ensure the `[[headers]]` blocks for `soffice.wasm.gz` and `soffice.data.gz` are in your `netlify.toml`.
### Build Fails ### Build Fails
Check Node version compatibility: Check Node version compatibility:
+58 -10
View File
@@ -17,6 +17,12 @@ npm install
npm run build npm run build
``` ```
To customize branding, set environment variables before building:
```bash
VITE_BRAND_NAME="AcmePDF" VITE_BRAND_LOGO="images/acme-logo.svg" npm run build
```
## Step 2: Copy Files ## Step 2: Copy Files
```bash ```bash
@@ -57,21 +63,51 @@ server {
application/wasm wasm; application/wasm wasm;
} }
# Cache static assets # Required headers for SharedArrayBuffer (LibreOffice WASM)
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|wasm)$ { # These must be on every response - especially HTML pages
expires 1y; add_header Cross-Origin-Embedder-Policy "require-corp" always;
add_header Cache-Control "public, immutable"; add_header Cross-Origin-Opener-Policy "same-origin" always;
} add_header Cross-Origin-Resource-Policy "cross-origin" always;
# SPA routing - serve index.html for all routes
location / {
try_files $uri $uri/ /index.html;
}
# Security headers # Security headers
add_header X-Frame-Options "SAMEORIGIN" always; add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-Content-Type-Options "nosniff" always; add_header X-Content-Type-Options "nosniff" always;
add_header X-XSS-Protection "1; mode=block" always; add_header X-XSS-Protection "1; mode=block" always;
# Pre-compressed LibreOffice WASM binary
location ~* /libreoffice-wasm/soffice\.wasm\.gz$ {
gzip off;
types {} default_type application/wasm;
add_header Content-Encoding gzip;
add_header Cache-Control "public, immutable";
add_header Cross-Origin-Embedder-Policy "require-corp" always;
add_header Cross-Origin-Opener-Policy "same-origin" always;
}
# Pre-compressed LibreOffice WASM data
location ~* /libreoffice-wasm/soffice\.data\.gz$ {
gzip off;
types {} default_type application/octet-stream;
add_header Content-Encoding gzip;
add_header Cache-Control "public, immutable";
add_header Cross-Origin-Embedder-Policy "require-corp" always;
add_header Cross-Origin-Opener-Policy "same-origin" always;
}
# Cache static assets
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|wasm)$ {
expires 1y;
add_header Cache-Control "public, immutable";
add_header Cross-Origin-Embedder-Policy "require-corp" always;
add_header Cross-Origin-Opener-Policy "same-origin" always;
}
# SPA routing - serve index.html for all routes
location / {
try_files $uri $uri/ /index.html;
add_header Cross-Origin-Embedder-Policy "require-corp" always;
add_header Cross-Origin-Opener-Policy "same-origin" always;
}
} }
``` ```
@@ -138,6 +174,18 @@ types {
} }
``` ```
### Word/ODT/Excel to PDF Not Working
LibreOffice WASM requires `SharedArrayBuffer`, which needs `Cross-Origin-Embedder-Policy` and `Cross-Origin-Opener-Policy` headers. Note that nginx `add_header` directives in a `location` block **override** server-level `add_header` directives — they don't merge. Every `location` block with its own `add_header` must include the COEP/COOP headers.
Verify with:
```bash
curl -I https://your-domain.com/ | grep -i cross-origin
```
If using a reverse proxy in front of nginx, ensure it preserves these headers.
### 502 Bad Gateway ### 502 Bad Gateway
Check Nginx error logs: Check Nginx error logs:
+56 -9
View File
@@ -18,20 +18,24 @@ Fork [bentopdf/bentopdf](https://github.com/alam00000/bentopdf) to your GitHub a
2. Select your forked repository 2. Select your forked repository
3. Configure the project: 3. Configure the project:
| Setting | Value | | Setting | Value |
|---------|-------| | ---------------- | --------------- |
| Framework Preset | Vite | | Framework Preset | Vite |
| Build Command | `npm run build` | | Build Command | `npm run build` |
| Output Directory | `dist` | | Output Directory | `dist` |
| Install Command | `npm install` | | Install Command | `npm install` |
### Step 3: Environment Variables (Optional) ### Step 3: Environment Variables (Optional)
Add these if needed: Add these if needed:
``` | Variable | Description |
SIMPLE_MODE=false | ----------------------- | ----------------------------------------------------------- |
``` | `SIMPLE_MODE` | Set to `true` for minimal UI |
| `VITE_BRAND_NAME` | Custom brand name (replaces "BentoPDF") |
| `VITE_BRAND_LOGO` | Logo path relative to `public/` (e.g. `images/my-logo.svg`) |
| `VITE_FOOTER_TEXT` | Custom footer/copyright text |
| `VITE_DEFAULT_LANGUAGE` | Default UI language (e.g. `fr`, `de`, `es`) |
### Step 4: Deploy ### Step 4: Deploy
@@ -73,3 +77,46 @@ Add a `vercel.json` for SPA routing:
"rewrites": [{ "source": "/(.*)", "destination": "/index.html" }] "rewrites": [{ "source": "/(.*)", "destination": "/index.html" }]
} }
``` ```
### Word/ODT/Excel to PDF Not Working
LibreOffice WASM conversions require `SharedArrayBuffer`, which needs these response headers on all pages:
- `Cross-Origin-Embedder-Policy: require-corp`
- `Cross-Origin-Opener-Policy: same-origin`
The pre-compressed `.wasm.gz` and `.data.gz` files also need `Content-Encoding: gzip` so the browser decompresses them.
Add these to your `vercel.json`:
```json
{
"rewrites": [{ "source": "/(.*)", "destination": "/index.html" }],
"headers": [
{
"source": "/(.*)",
"headers": [
{ "key": "Cross-Origin-Embedder-Policy", "value": "require-corp" },
{ "key": "Cross-Origin-Opener-Policy", "value": "same-origin" },
{ "key": "Cross-Origin-Resource-Policy", "value": "cross-origin" }
]
},
{
"source": "/libreoffice-wasm/soffice.wasm.gz",
"headers": [
{ "key": "Content-Type", "value": "application/wasm" },
{ "key": "Content-Encoding", "value": "gzip" }
]
},
{
"source": "/libreoffice-wasm/soffice.data.gz",
"headers": [
{ "key": "Content-Type", "value": "application/octet-stream" },
{ "key": "Content-Encoding", "value": "gzip" }
]
}
]
}
```
To verify, open DevTools Console and run `console.log(window.crossOriginIsolated)` — it should return `true`.
+42
View File
@@ -0,0 +1,42 @@
#!/bin/sh
set -e
PUID=${PUID:-1000}
PGID=${PGID:-1000}
# Validate PUID/PGID
case "$PUID" in
''|*[!0-9]*) echo "ERROR: PUID must be a number, got '$PUID'" >&2; exit 1 ;;
esac
case "$PGID" in
''|*[!0-9]*) echo "ERROR: PGID must be a number, got '$PGID'" >&2; exit 1 ;;
esac
if [ "$PUID" -eq 0 ] || [ "$PGID" -eq 0 ]; then
echo "ERROR: PUID/PGID cannot be 0 (root). Use the standard Dockerfile instead." >&2
exit 1
fi
echo "Starting BentoPDF with PUID=$PUID PGID=$PGID"
addgroup -g "$PGID" bentopdf 2>/dev/null || true
adduser -u "$PUID" -G bentopdf -D -H -s /sbin/nologin bentopdf 2>/dev/null || true
rm -f /var/log/nginx/error.log /var/log/nginx/access.log
touch /var/log/nginx/error.log /var/log/nginx/access.log
chown "$PUID:$PGID" /var/log/nginx /var/log/nginx/error.log /var/log/nginx/access.log
sed -i '1i error_log stderr warn;' /etc/nginx/nginx.conf
sed -i '/^http {/a\ access_log /var/log/nginx/access.log;' /etc/nginx/nginx.conf
chown -R "$PUID:$PGID" \
/etc/nginx/tmp \
/var/cache/nginx \
/usr/share/nginx/html \
/etc/nginx/nginx.conf
if [ "$DISABLE_IPV6" = "true" ]; then
echo "Disabling Nginx IPv6 listener"
sed -i '/^[[:space:]]*listen[[:space:]]*\[::\]:[0-9]*/s/^/#/' /etc/nginx/nginx.conf
fi
exec su-exec "$PUID:$PGID" "$@"
+32
View File
@@ -717,6 +717,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>
<!-- Advanced Settings Link --> <!-- Advanced Settings Link -->
<a <a
href="wasm-settings.html" href="wasm-settings.html"
+15 -16
View File
@@ -27,6 +27,21 @@ http {
index index.html; index index.html;
absolute_redirect off; absolute_redirect off;
location ~ ^/(en|ar|be|da|de|es|fr|id|it|nl|pt|tr|vi|zh|zh-TW)(/.*)?$ {
try_files $uri $uri/ $uri.html /$1/index.html /index.html;
expires 5m;
add_header Cache-Control "public, must-revalidate";
add_header Cross-Origin-Embedder-Policy "require-corp" always;
add_header Cross-Origin-Opener-Policy "same-origin" always;
}
location ~ ^/(.+?)/(en|ar|be|da|de|es|fr|id|it|nl|pt|tr|vi|zh|zh-TW)(/.*)?$ {
try_files $uri $uri/ $uri.html /$1/$2/index.html /$1/index.html /index.html;
expires 5m;
add_header Cache-Control "public, must-revalidate";
add_header Cross-Origin-Embedder-Policy "require-corp" always;
add_header Cross-Origin-Opener-Policy "same-origin" always;
}
location ~* \.html$ { location ~* \.html$ {
expires 1h; expires 1h;
@@ -84,22 +99,6 @@ http {
add_header Cache-Control "public, immutable"; add_header Cache-Control "public, immutable";
} }
location ~ ^/(en|de|es|zh|zh-TW|vi|it|id|tr|fr|pt|be)(/.*)?$ {
try_files $uri $uri/ $uri.html /$1/index.html /index.html;
expires 5m;
add_header Cache-Control "public, must-revalidate";
add_header Cross-Origin-Embedder-Policy "require-corp" always;
add_header Cross-Origin-Opener-Policy "same-origin" always;
}
location ~ ^/(.+?)/(en|de|es|zh|zh-TW|vi|it|id|tr|fr|pt|be)(/.*)?$ {
try_files $uri $uri/ $uri.html /$1/$2/index.html /$1/index.html /index.html;
expires 5m;
add_header Cache-Control "public, must-revalidate";
add_header Cross-Origin-Embedder-Policy "require-corp" always;
add_header Cross-Origin-Opener-Policy "same-origin" always;
}
location / { location / {
try_files $uri $uri/ $uri.html /index.html; try_files $uri $uri/ $uri.html /index.html;
expires 5m; expires 5m;
+28 -27
View File
@@ -1,12 +1,12 @@
{ {
"name": "bento-pdf", "name": "bento-pdf",
"version": "2.1.0", "version": "2.2.1",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "bento-pdf", "name": "bento-pdf",
"version": "2.1.0", "version": "2.2.1",
"license": "AGPL-3.0-only", "license": "AGPL-3.0-only",
"dependencies": { "dependencies": {
"@fontsource/cedarville-cursive": "^5.2.7", "@fontsource/cedarville-cursive": "^5.2.7",
@@ -36,12 +36,12 @@
"i18next": "^25.7.2", "i18next": "^25.7.2",
"i18next-browser-languagedetector": "^8.2.0", "i18next-browser-languagedetector": "^8.2.0",
"i18next-http-backend": "^3.0.2", "i18next-http-backend": "^3.0.2",
"jspdf": "^4.1.0", "jspdf": "^4.2.0",
"jspdf-autotable": "^5.0.2", "jspdf-autotable": "^5.0.2",
"jszip": "^3.10.1", "jszip": "^3.10.1",
"lit": "^3.3.2", "lit": "^3.3.2",
"lucide": "^0.546.0", "lucide": "^0.546.0",
"markdown-it": "^14.1.0", "markdown-it": "^14.1.1",
"markdown-it-abbr": "^2.0.0", "markdown-it-abbr": "^2.0.0",
"markdown-it-anchor": "^9.2.0", "markdown-it-anchor": "^9.2.0",
"markdown-it-deflist": "^3.0.0", "markdown-it-deflist": "^3.0.0",
@@ -72,7 +72,7 @@
"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.1.4",
"xlsx": "https://cdn.sheetjs.com/xlsx-latest/xlsx-latest.tgz", "xlsx": "https://cdn.sheetjs.com/xlsx-0.20.3/xlsx-0.20.3.tgz",
"zgapdfsigner": "^2.7.5" "zgapdfsigner": "^2.7.5"
}, },
"devDependencies": { "devDependencies": {
@@ -480,9 +480,9 @@
} }
}, },
"node_modules/@babel/runtime": { "node_modules/@babel/runtime": {
"version": "7.28.4", "version": "7.28.6",
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.4.tgz", "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.6.tgz",
"integrity": "sha512-Q/N6JNWvIvPnLDvjlE1OUBLPQHH6l3CltCEsHIujp45zQUSSh8K+gHnaEX45yAT1nyngnINhvWtzN+Nb9D8RAQ==", "integrity": "sha512-05WQkdpL9COIMz4LjTxGpPNCdlpyimKppYNoJ5Di5EUObifl8t4tuLuUBBZEpoLYOmfvIWrsp9fCl0HoPRVTdA==",
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": ">=6.9.0" "node": ">=6.9.0"
@@ -6432,9 +6432,9 @@
} }
}, },
"node_modules/devalue": { "node_modules/devalue": {
"version": "5.6.2", "version": "5.6.3",
"resolved": "https://registry.npmjs.org/devalue/-/devalue-5.6.2.tgz", "resolved": "https://registry.npmjs.org/devalue/-/devalue-5.6.3.tgz",
"integrity": "sha512-nPRkjWzzDQlsejL1WVifk5rvcFi/y1onBRxjaFMjZeR9mFpqu2gmAZ9xUB9/IEanEP/vBtGeGganC/GO1fmufg==", "integrity": "sha512-nc7XjUU/2Lb+SvEFVGcWLiKkzfw8+qHI7zn8WYXKkLMgfGSHbgCEaR6bJpev8Cm6Rmrb19Gfd/tZvGqx9is3wg==",
"license": "MIT", "license": "MIT",
"peer": true "peer": true
}, },
@@ -8228,12 +8228,12 @@
} }
}, },
"node_modules/jspdf": { "node_modules/jspdf": {
"version": "4.1.0", "version": "4.2.0",
"resolved": "https://registry.npmjs.org/jspdf/-/jspdf-4.1.0.tgz", "resolved": "https://registry.npmjs.org/jspdf/-/jspdf-4.2.0.tgz",
"integrity": "sha512-xd1d/XRkwqnsq6FP3zH1Q+Ejqn2ULIJeDZ+FTKpaabVpZREjsJKRJwuokTNgdqOU+fl55KgbvgZ1pRTSWCP2kQ==", "integrity": "sha512-hR/hnRevAXXlrjeqU5oahOE+Ln9ORJUB5brLHHqH67A+RBQZuFr5GkbI9XQI8OUFSEezKegsi45QRpc4bGj75Q==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@babel/runtime": "^7.28.4", "@babel/runtime": "^7.28.6",
"fast-png": "^6.2.0", "fast-png": "^6.2.0",
"fflate": "^0.8.1" "fflate": "^0.8.1"
}, },
@@ -9050,9 +9050,9 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/markdown-it": { "node_modules/markdown-it": {
"version": "14.1.0", "version": "14.1.1",
"resolved": "https://registry.npmjs.org/markdown-it/-/markdown-it-14.1.0.tgz", "resolved": "https://registry.npmjs.org/markdown-it/-/markdown-it-14.1.1.tgz",
"integrity": "sha512-a54IwgWPaeBCAAsv13YgmALOF1elABB08FxO9i+r4VFk5Vl4pKokRPeX8u5TCgSsPi6ec1otfLjdOpVcgbpshg==", "integrity": "sha512-BuU2qnTti9YKgK5N+IeMubp14ZUKUUw7yeJbkjtosvHiP0AZ5c8IAgEMk79D0eC8F23r4Ac/q8cAIFdm2FtyoA==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"argparse": "^2.0.1", "argparse": "^2.0.1",
@@ -10309,9 +10309,9 @@
} }
}, },
"node_modules/qs": { "node_modules/qs": {
"version": "6.14.1", "version": "6.15.0",
"resolved": "https://registry.npmjs.org/qs/-/qs-6.14.1.tgz", "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.0.tgz",
"integrity": "sha512-4EK3+xJl8Ts67nLYNwqw/dsFVnCf+qR7RgXSK9jEEm9unao3njwMDdmsdvoKBKHzxd7tCYz5e5M+SnMjdtXGQQ==", "integrity": "sha512-mAZTtNCeetKMH+pSjrb76NAM8V9a05I9aBZOHztWy/UqcJdQYNsf59vrRKWnojAT9Y+GbIvoTBC++CPHqpDBhQ==",
"dev": true, "dev": true,
"license": "BSD-3-Clause", "license": "BSD-3-Clause",
"dependencies": { "dependencies": {
@@ -11442,9 +11442,9 @@
} }
}, },
"node_modules/svelte": { "node_modules/svelte": {
"version": "5.48.2", "version": "5.53.1",
"resolved": "https://registry.npmjs.org/svelte/-/svelte-5.48.2.tgz", "resolved": "https://registry.npmjs.org/svelte/-/svelte-5.53.1.tgz",
"integrity": "sha512-VPWD+UyoSFZ7Nxix5K/F8yWiKWOiROkLlWYXOZReE0TUycw+58YWB3D6lAKT+57xmN99wRX4H3oZmw0NPy7y3Q==", "integrity": "sha512-WzxFHZhhD23Qzu7JCYdvm1rxvRSzdt9HtHO8TScMBX51bLRFTcJmATVqjqXG+6Ln6hrViGCo9DzwOhAasxwC/w==",
"license": "MIT", "license": "MIT",
"peer": true, "peer": true,
"dependencies": { "dependencies": {
@@ -11452,13 +11452,14 @@
"@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/sourcemap-codec": "^1.5.0",
"@sveltejs/acorn-typescript": "^1.0.5", "@sveltejs/acorn-typescript": "^1.0.5",
"@types/estree": "^1.0.5", "@types/estree": "^1.0.5",
"@types/trusted-types": "^2.0.7",
"acorn": "^8.12.1", "acorn": "^8.12.1",
"aria-query": "^5.3.1", "aria-query": "^5.3.1",
"axobject-query": "^4.1.0", "axobject-query": "^4.1.0",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"devalue": "^5.6.2", "devalue": "^5.6.3",
"esm-env": "^1.2.1", "esm-env": "^1.2.1",
"esrap": "^2.2.1", "esrap": "^2.2.2",
"is-reference": "^3.0.3", "is-reference": "^3.0.3",
"locate-character": "^3.0.0", "locate-character": "^3.0.0",
"magic-string": "^0.30.11", "magic-string": "^0.30.11",
@@ -13852,7 +13853,7 @@
}, },
"node_modules/xlsx": { "node_modules/xlsx": {
"version": "0.20.3", "version": "0.20.3",
"resolved": "https://cdn.sheetjs.com/xlsx-latest/xlsx-latest.tgz", "resolved": "https://cdn.sheetjs.com/xlsx-0.20.3/xlsx-0.20.3.tgz",
"integrity": "sha512-oLDq3jw7AcLqKWH2AhCpVTZl8mf6X2YReP+Neh0SJUzV/BdZYjth94tG5toiMB1PPrYtxOCfaoUCkvtuH+3AJA==", "integrity": "sha512-oLDq3jw7AcLqKWH2AhCpVTZl8mf6X2YReP+Neh0SJUzV/BdZYjth94tG5toiMB1PPrYtxOCfaoUCkvtuH+3AJA==",
"license": "Apache-2.0", "license": "Apache-2.0",
"bin": { "bin": {
+4 -4
View File
@@ -1,7 +1,7 @@
{ {
"name": "bento-pdf", "name": "bento-pdf",
"private": true, "private": true,
"version": "2.2.0", "version": "2.3.3",
"license": "AGPL-3.0-only", "license": "AGPL-3.0-only",
"type": "module", "type": "module",
"scripts": { "scripts": {
@@ -92,12 +92,12 @@
"i18next": "^25.7.2", "i18next": "^25.7.2",
"i18next-browser-languagedetector": "^8.2.0", "i18next-browser-languagedetector": "^8.2.0",
"i18next-http-backend": "^3.0.2", "i18next-http-backend": "^3.0.2",
"jspdf": "^4.1.0", "jspdf": "^4.2.0",
"jspdf-autotable": "^5.0.2", "jspdf-autotable": "^5.0.2",
"jszip": "^3.10.1", "jszip": "^3.10.1",
"lit": "^3.3.2", "lit": "^3.3.2",
"lucide": "^0.546.0", "lucide": "^0.546.0",
"markdown-it": "^14.1.0", "markdown-it": "^14.1.1",
"markdown-it-abbr": "^2.0.0", "markdown-it-abbr": "^2.0.0",
"markdown-it-anchor": "^9.2.0", "markdown-it-anchor": "^9.2.0",
"markdown-it-deflist": "^3.0.0", "markdown-it-deflist": "^3.0.0",
@@ -128,7 +128,7 @@
"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.1.4",
"xlsx": "https://cdn.sheetjs.com/xlsx-latest/xlsx-latest.tgz", "xlsx": "https://cdn.sheetjs.com/xlsx-0.20.3/xlsx-0.20.3.tgz",
"zgapdfsigner": "^2.7.5" "zgapdfsigner": "^2.7.5"
}, },
"lint-staged": { "lint-staged": {
+4
View File
@@ -357,5 +357,9 @@
"errorRendering": "فشل عرض الصور المصغرة للصفحات", "errorRendering": "فشل عرض الصور المصغرة للصفحات",
"error": "خطأ", "error": "خطأ",
"failedToLoad": "فشل التحميل" "failedToLoad": "فشل التحميل"
},
"simpleMode": {
"title": "أدوات PDF",
"subtitle": "اختر أداة للبدء"
} }
} }
+6 -1
View File
@@ -86,9 +86,14 @@
"name": "أرقام الصفحات", "name": "أرقام الصفحات",
"subtitle": "إدراج أرقام الصفحات في مستندك." "subtitle": "إدراج أرقام الصفحات في مستندك."
}, },
"batesNumbering": {
"name": "ترقيم بيتس",
"subtitle": "إضافة أرقام بيتس التسلسلية عبر ملف PDF واحد أو أكثر."
},
"addWatermark": { "addWatermark": {
"name": "إضافة علامة مائية", "name": "إضافة علامة مائية",
"subtitle": "ختم نص أو صورة على صفحات PDF الخاصة بك." "subtitle": "ختم نص أو صورة على صفحات PDF الخاصة بك.",
"applyToAllPages": "تطبيق على جميع الصفحات"
}, },
"headerFooter": { "headerFooter": {
"name": "رأس وتذييل", "name": "رأس وتذييل",
+4
View File
@@ -355,5 +355,9 @@
}, },
"relatedTools": { "relatedTools": {
"title": "Звязаныя інструменты PDF" "title": "Звязаныя інструменты PDF"
},
"simpleMode": {
"title": "Інструменты PDF",
"subtitle": "Выберыце інструмент, каб пачаць"
} }
} }
+16 -11
View File
@@ -86,9 +86,14 @@
"name": "Нумары старонак", "name": "Нумары старонак",
"subtitle": "Уставіць у дакумент нумары старонак." "subtitle": "Уставіць у дакумент нумары старонак."
}, },
"batesNumbering": {
"name": "Нумарацыя Бейтса",
"subtitle": "Дадаць паслядоўныя нумары Бейтса да аднаго або некалькіх файлаў PDF."
},
"addWatermark": { "addWatermark": {
"name": "Дадаць вадзяны знак", "name": "Дадаць вадзяны знак",
"subtitle": "Накласці на старонкі PDF тэкст або відарыс." "subtitle": "Накласці на старонкі PDF тэкст або відарыс.",
"applyToAllPages": "Прымяніць да ўсіх старонак"
}, },
"headerFooter": { "headerFooter": {
"name": "Верхні і ніжні калантытул", "name": "Верхні і ніжні калантытул",
@@ -590,10 +595,10 @@
"subtitle": "Канвертаваць PDF у PDF на аснове відарысаў. Звесці слаі і выдаліць тэкст, які можна вылучыць." "subtitle": "Канвертаваць PDF у PDF на аснове відарысаў. Звесці слаі і выдаліць тэкст, які можна вылучыць."
}, },
"pdfWorkflow": { "pdfWorkflow": {
"name": "Канструктар працоўнага працэсу PDF", "name": "Канструктар рабочага працэсу PDF",
"subtitle": "Стварайце ўласныя канвееры апрацоўкі PDF з дапамогай візуальнага рэдактара вузлоў.", "subtitle": "Стварыць уласныя канвееры апрацоўкі PDF з дапамогай візуальнага рэдактара.",
"nodes": "Вузлы", "nodes": "Вузлы",
"searchNodes": "Шукаць вузлы...", "searchNodes": "Пошук вузлоў...",
"run": "Запусціць", "run": "Запусціць",
"clear": "Ачысціць", "clear": "Ачысціць",
"save": "Захаваць", "save": "Захаваць",
@@ -605,21 +610,21 @@
"processing": "Апрацоўка...", "processing": "Апрацоўка...",
"saveTemplate": "Захаваць шаблон", "saveTemplate": "Захаваць шаблон",
"templateName": "Назва шаблону", "templateName": "Назва шаблону",
"templatePlaceholder": "напр. Працоўны працэс рахункаў-фактур", "templatePlaceholder": "напрыклад, Праца з накладнымі",
"cancel": "Адмяніць", "cancel": "Скасаваць",
"loadTemplate": "Загрузіць шаблон", "loadTemplate": "Загрузіць шаблон",
"noTemplates": "Пакуль няма захаваных шаблонаў.", "noTemplates": "Пакуль няма захаваных шаблонаў.",
"ok": "OK", "ok": "OK",
"workflowCompleted": "Працоўны працэс завершаны", "workflowCompleted": "Рабочы працэс завершаны",
"errorDuringExecution": "Памылка падчас выканання", "errorDuringExecution": "Памылка падчас выканання",
"addNodeError": "Дадайце хаця б адзін вузел для запуску працоўнага працэсу.", "addNodeError": "Дадайце хаця б адзін вузел для запуску рабочага працэсу.",
"needInputOutput": "Для працы працоўнага працэсу патрэбен хаця б адзін вузел уводу і адзін вузел вываду.", "needInputOutput": "Для працы рабочага працэсу патрэбен хаця б адзін вузел уводу і адзін вузел вываду.",
"enterName": "Калі ласка, увядзіце назву.", "enterName": "Увядзіце назву.",
"templateExists": "Шаблон з такой назвай ужо існуе.", "templateExists": "Шаблон з такой назвай ужо існуе.",
"templateSaved": "Шаблон \"{{name}}\" захаваны.", "templateSaved": "Шаблон \"{{name}}\" захаваны.",
"templateLoaded": "Шаблон \"{{name}}\" загружаны.", "templateLoaded": "Шаблон \"{{name}}\" загружаны.",
"failedLoadTemplate": "Не ўдалося загрузіць шаблон.", "failedLoadTemplate": "Не ўдалося загрузіць шаблон.",
"noSettings": "Няма наладжвальных параметраў для гэтага вузла.", "noSettings": "Няма налад для гэтага вузла.",
"advancedSettings": "Пашыраныя налады" "advancedSettings": "Пашыраныя налады"
} }
} }
+4
View File
@@ -357,5 +357,9 @@
"errorRendering": "Kunne ikke rendere side-miniaturer", "errorRendering": "Kunne ikke rendere side-miniaturer",
"error": "Fejl", "error": "Fejl",
"failedToLoad": "Kunne ikke indlæses" "failedToLoad": "Kunne ikke indlæses"
},
"simpleMode": {
"title": "PDF-værktøjer",
"subtitle": "Vælg et værktøj for at komme i gang"
} }
} }
+6 -1
View File
@@ -86,9 +86,14 @@
"name": "Sidetal", "name": "Sidetal",
"subtitle": "Indsæt sidetal i dokumentet." "subtitle": "Indsæt sidetal i dokumentet."
}, },
"batesNumbering": {
"name": "Bates-nummerering",
"subtitle": "Tilføj sekventielle Bates-numre på tværs af en eller flere PDF-filer."
},
"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",
+4
View File
@@ -357,5 +357,9 @@
}, },
"relatedTools": { "relatedTools": {
"title": "Verwandte PDF-Tools" "title": "Verwandte PDF-Tools"
},
"simpleMode": {
"title": "PDF-Werkzeuge",
"subtitle": "Wählen Sie ein Werkzeug aus, um zu beginnen"
} }
} }
+6 -1
View File
@@ -86,9 +86,14 @@
"name": "Seitenzahlen", "name": "Seitenzahlen",
"subtitle": "Seitenzahlen in Ihr Dokument einfügen." "subtitle": "Seitenzahlen in Ihr Dokument einfügen."
}, },
"batesNumbering": {
"name": "Bates-Nummerierung",
"subtitle": "Fortlaufende Bates-Nummern über eine oder mehrere PDF-Dateien hinzufügen."
},
"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",
+4
View File
@@ -357,5 +357,9 @@
"errorRendering": "Failed to render page thumbnails", "errorRendering": "Failed to render page thumbnails",
"error": "Error", "error": "Error",
"failedToLoad": "Failed to load" "failedToLoad": "Failed to load"
},
"simpleMode": {
"title": "PDF Tools",
"subtitle": "Select a tool to get started"
} }
} }
+6 -1
View File
@@ -86,9 +86,14 @@
"name": "Page Numbers", "name": "Page Numbers",
"subtitle": "Insert page numbers into your document." "subtitle": "Insert page numbers into your document."
}, },
"batesNumbering": {
"name": "Bates Numbering",
"subtitle": "Add sequential Bates numbers across one or more PDF files."
},
"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",
+4
View File
@@ -357,5 +357,9 @@
}, },
"relatedTools": { "relatedTools": {
"title": "Herramientas PDF relacionadas" "title": "Herramientas PDF relacionadas"
},
"simpleMode": {
"title": "Herramientas PDF",
"subtitle": "Selecciona una herramienta para comenzar"
} }
} }
+6 -1
View File
@@ -86,9 +86,14 @@
"name": "Números de Página", "name": "Números de Página",
"subtitle": "Inserta números de página en tu documento." "subtitle": "Inserta números de página en tu documento."
}, },
"batesNumbering": {
"name": "Numeración Bates",
"subtitle": "Añadir números Bates secuenciales en uno o más archivos PDF."
},
"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",
+4
View File
@@ -357,5 +357,9 @@
}, },
"relatedTools": { "relatedTools": {
"title": "Outils PDF associés" "title": "Outils PDF associés"
},
"simpleMode": {
"title": "Outils PDF",
"subtitle": "Sélectionnez un outil pour commencer"
} }
} }
+6 -1
View File
@@ -86,9 +86,14 @@
"name": "Numéros de page", "name": "Numéros de page",
"subtitle": "Insérer une numérotation dans le document." "subtitle": "Insérer une numérotation dans le document."
}, },
"batesNumbering": {
"name": "Numérotation Bates",
"subtitle": "Ajouter des numéros Bates séquentiels sur un ou plusieurs fichiers PDF."
},
"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",
+4
View File
@@ -357,5 +357,9 @@
}, },
"relatedTools": { "relatedTools": {
"title": "Alat PDF Terkait" "title": "Alat PDF Terkait"
},
"simpleMode": {
"title": "Alat PDF",
"subtitle": "Pilih alat untuk memulai"
} }
} }
+6 -1
View File
@@ -86,9 +86,14 @@
"name": "Nomor Halaman", "name": "Nomor Halaman",
"subtitle": "Sisipkan nomor halaman ke dokumen Anda." "subtitle": "Sisipkan nomor halaman ke dokumen Anda."
}, },
"batesNumbering": {
"name": "Penomoran Bates",
"subtitle": "Tambahkan nomor Bates berurutan pada satu atau lebih file PDF."
},
"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",
+4
View File
@@ -357,5 +357,9 @@
}, },
"relatedTools": { "relatedTools": {
"title": "Strumenti PDF correlati" "title": "Strumenti PDF correlati"
},
"simpleMode": {
"title": "Strumenti PDF",
"subtitle": "Seleziona uno strumento per iniziare"
} }
} }
+6 -1
View File
@@ -86,9 +86,14 @@
"name": "Numeri di Pagina", "name": "Numeri di Pagina",
"subtitle": "Inserisci i numeri di pagina nel tuo documento." "subtitle": "Inserisci i numeri di pagina nel tuo documento."
}, },
"batesNumbering": {
"name": "Numerazione Bates",
"subtitle": "Aggiungi numeri Bates sequenziali su uno o più file PDF."
},
"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",
+16 -11
View File
@@ -56,7 +56,7 @@
"title": "Aan de slag met", "title": "Aan de slag met",
"toolsLabel": "Tools", "toolsLabel": "Tools",
"subtitle": "Klik een tool om de bestandslader te openen", "subtitle": "Klik een tool om de bestandslader te openen",
"searchPlaceholder": "Zoek een tool (bijv., 'splitsen', 'organiseren'...)", "searchPlaceholder": "Zoek een tool (bijv. 'splitsen', 'organiseren', ...)",
"backToTools": "Terug naar Tools", "backToTools": "Terug naar Tools",
"firstLoadNotice": "De eerste keer duurt het even om onze conversiemachine te laden. Daarna gaat alles meteen." "firstLoadNotice": "De eerste keer duurt het even om onze conversiemachine te laden. Daarna gaat alles meteen."
}, },
@@ -95,6 +95,15 @@
"webpImages": "WebP-afbeeldingen" "webpImages": "WebP-afbeeldingen"
} }
}, },
"howItWorks": {
"title": "Hoe het werkt",
"step1": "Klik of sleep uw bestand hierheen",
"step2": "Klik op de verwerkingsknop",
"step3": "Sla uw verwerkte bestand direct op"
},
"relatedTools": {
"title": "Gerelateerde PDF-tools"
},
"loader": { "loader": {
"processing": "Verwerken..." "processing": "Verwerken..."
}, },
@@ -166,6 +175,7 @@
"faq": { "faq": {
"title": "Veelgestelde", "title": "Veelgestelde",
"questions": "Vragen", "questions": "Vragen",
"sectionTitle": "Veelgestelde vragen",
"isFree": { "isFree": {
"question": "Is BentoPDF echt gratis?", "question": "Is BentoPDF echt gratis?",
"answer": "Ja, absoluut. Alle tools op BentoPDF zijn 100% gratis te gebruiken, zonder bestandslimieten, zonder aanmeldingen en zonder watermerken. Wij vinden dat iedereen toegang verdient tot eenvoudige, krachtige PDF-tools zonder betaalmuur." "answer": "Ja, absoluut. Alle tools op BentoPDF zijn 100% gratis te gebruiken, zonder bestandslimieten, zonder aanmeldingen en zonder watermerken. Wij vinden dat iedereen toegang verdient tot eenvoudige, krachtige PDF-tools zonder betaalmuur."
@@ -197,8 +207,7 @@
"analytics": { "analytics": {
"question": "Gebruikt BentoPDF cookies of analytics om mij te volgen?", "question": "Gebruikt BentoPDF cookies of analytics om mij te volgen?",
"answer": "We geven om je privacy. BentoPDF houdt geen persoonlijke gegevens bij. We gebruiken Simple Analytics alleen om anonieme bezoekersaantallen te zien. Dit betekent dat we kunnen weten hoeveel mensen onze site bezoeken, maar we weten nooit wie jij bent. Simple Analytics is volledig AVG-conform en respecteert je privacy." "answer": "We geven om je privacy. BentoPDF houdt geen persoonlijke gegevens bij. We gebruiken Simple Analytics alleen om anonieme bezoekersaantallen te zien. Dit betekent dat we kunnen weten hoeveel mensen onze site bezoeken, maar we weten nooit wie jij bent. Simple Analytics is volledig AVG-conform en respecteert je privacy."
}, }
"sectionTitle": "Veelgestelde vragen"
}, },
"testimonials": { "testimonials": {
"title": "Wat onze", "title": "Wat onze",
@@ -349,13 +358,9 @@
"error": "Fout", "error": "Fout",
"failedToLoad": "Laden is mislukt" "failedToLoad": "Laden is mislukt"
}, },
"howItWorks": { "simpleMode": {
"title": "Hoe het werkt", "title": "PDF-tools",
"step1": "Klik of sleep uw bestand hierheen", "subtitle": "Selecteer een tool om te beginnen"
"step2": "Klik op de verwerkingsknop",
"step3": "Sla uw verwerkte bestand direct op"
},
"relatedTools": {
"title": "Gerelateerde PDF-tools"
} }
} }
+14 -9
View File
@@ -86,9 +86,14 @@
"name": "Paginanummers", "name": "Paginanummers",
"subtitle": "Paginanummers aan je document toevoegen." "subtitle": "Paginanummers aan je document toevoegen."
}, },
"batesNumbering": {
"name": "Bates-nummering",
"subtitle": "Voeg opeenvolgende Bates-nummers toe aan een of meer PDF-bestanden."
},
"addWatermark": { "addWatermark": {
"name": "Watermerk toevoegen", "name": "Watermerk toevoegen",
"subtitle": "Tekst of een afbeelding over de pagina's van je PDF stempelen." "subtitle": "Tekst of een afbeelding over de pagina's van je PDF stempelen.",
"applyToAllPages": "Toepassen op alle pagina's"
}, },
"headerFooter": { "headerFooter": {
"name": "Koptekst & Voettekst", "name": "Koptekst & Voettekst",
@@ -590,10 +595,10 @@
"subtitle": "Converteer PDF naar beeldgebaseerde PDF. Lagen afvlakken en selecteerbare tekst verwijderen." "subtitle": "Converteer PDF naar beeldgebaseerde PDF. Lagen afvlakken en selecteerbare tekst verwijderen."
}, },
"pdfWorkflow": { "pdfWorkflow": {
"name": "PDF Workflow Builder", "name": "PDF Werkproces Samenstellen",
"subtitle": "Bouw aangepaste PDF-verwerkingspipelines met een visuele node-editor.", "subtitle": "Bouw specifieke PDF-verwerkingsprocessen met een visuele knooppunten-bewerking.",
"nodes": "Nodes", "nodes": "Knooppunten",
"searchNodes": "Nodes zoeken...", "searchNodes": "Knooppunten zoeken...",
"run": "Uitvoeren", "run": "Uitvoeren",
"clear": "Wissen", "clear": "Wissen",
"save": "Opslaan", "save": "Opslaan",
@@ -605,15 +610,15 @@
"processing": "Verwerken...", "processing": "Verwerken...",
"saveTemplate": "Sjabloon opslaan", "saveTemplate": "Sjabloon opslaan",
"templateName": "Sjabloonnaam", "templateName": "Sjabloonnaam",
"templatePlaceholder": "bijv. Factuur-workflow", "templatePlaceholder": "bijv. Factuur-werkproces",
"cancel": "Annuleren", "cancel": "Annuleren",
"loadTemplate": "Sjabloon laden", "loadTemplate": "Sjabloon laden",
"noTemplates": "Nog geen opgeslagen sjablonen.", "noTemplates": "Nog geen opgeslagen sjablonen.",
"ok": "OK", "ok": "OK",
"workflowCompleted": "Workflow voltooid", "workflowCompleted": "Werkproces voltooid",
"errorDuringExecution": "Fout tijdens uitvoering", "errorDuringExecution": "Fout tijdens uitvoering",
"addNodeError": "Voeg minimaal één node toe om de workflow uit te voeren.", "addNodeError": "Voeg minimaal één knooppunt toe om het werkproces uit te voeren.",
"needInputOutput": "Uw workflow heeft minimaal één invoer-node en één uitvoer-node nodig om te kunnen draaien.", "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.",
+4
View File
@@ -357,5 +357,9 @@
}, },
"relatedTools": { "relatedTools": {
"title": "Ferramentas PDF relacionadas" "title": "Ferramentas PDF relacionadas"
},
"simpleMode": {
"title": "Ferramentas PDF",
"subtitle": "Selecione uma ferramenta para começar"
} }
} }
+134 -129
View File
@@ -64,9 +64,14 @@
"name": "Números de Página", "name": "Números de Página",
"subtitle": "Insira números de página no seu documento." "subtitle": "Insira números de página no seu documento."
}, },
"batesNumbering": {
"name": "Numeração Bates",
"subtitle": "Adicionar números Bates sequenciais em um ou mais arquivos PDF."
},
"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é",
@@ -203,7 +208,7 @@
"subtitle": "Converta arquivos PDF para o formato JSON." "subtitle": "Converta arquivos PDF para o formato JSON."
}, },
"ocrPdf": { "ocrPdf": {
"name": "OCR PDF", "name": "OCR para PDF",
"subtitle": "Torne um PDF pesquisável e copiável (reconhecimento de texto)." "subtitle": "Torne um PDF pesquisável e copiável (reconhecimento de texto)."
}, },
"alternateMix": { "alternateMix": {
@@ -325,192 +330,192 @@
"subtitle": "Endireite automaticamente páginas digitalizadas inclinadas usando OpenCV." "subtitle": "Endireite automaticamente páginas digitalizadas inclinadas usando OpenCV."
}, },
"rotateCustom": { "rotateCustom": {
"name": "Rotate by Custom Degrees", "name": "Rotacionar em Graus Customizáveis",
"subtitle": "Rotate pages by any custom angle." "subtitle": "Rotaciona páginas em qualquer ângulo customizado."
}, },
"odtToPdf": { "odtToPdf": {
"name": "ODT to PDF", "name": "ODT para PDF",
"subtitle": "Convert OpenDocument Text files to PDF format. Supports multiple files.", "subtitle": "Converte arquivos em formato OpenDocument Text para PDF. Suporta multiplos arquivos.",
"acceptedFormats": "ODT files", "acceptedFormats": "Arquivos ODT",
"convertButton": "Convert to PDF" "convertButton": "Converter para PDF"
}, },
"csvToPdf": { "csvToPdf": {
"name": "CSV to PDF", "name": "CSV para PDF",
"subtitle": "Convert CSV spreadsheet files to PDF format. Supports multiple files.", "subtitle": "Converte arquivos de planilhas CSV para PDF. Suporta múltiplos arquivos.",
"acceptedFormats": "CSV files", "acceptedFormats": "Arquivos CSV",
"convertButton": "Convert to PDF" "convertButton": "Converter para PDF"
}, },
"rtfToPdf": { "rtfToPdf": {
"name": "RTF to PDF", "name": "RTF para PDF",
"subtitle": "Convert Rich Text Format documents to PDF. Supports multiple files.", "subtitle": "Converte documentos Rich Text Format para PDF. Suporta múltiplos arquivos.",
"acceptedFormats": "RTF files", "acceptedFormats": "Arquivos RTF",
"convertButton": "Convert to PDF" "convertButton": "Converter para PDF"
}, },
"wordToPdf": { "wordToPdf": {
"name": "Word to PDF", "name": "Word para PDF",
"subtitle": "Convert Word documents (DOCX, DOC, ODT, RTF) to PDF format. Supports multiple files.", "subtitle": "Converte documentos Word documents (DOCX, DOC, ODT, RTF) para PDF. Suporta múltiplos arquivos.",
"acceptedFormats": "DOCX, DOC, ODT, RTF files", "acceptedFormats": "Arquivos DOCX, DOC, ODT, RTF",
"convertButton": "Convert to PDF" "convertButton": "Converter para PDF"
}, },
"excelToPdf": { "excelToPdf": {
"name": "Excel to PDF", "name": "Excel para PDF",
"subtitle": "Convert Excel spreadsheets (XLSX, XLS, ODS, CSV) to PDF format. Supports multiple files.", "subtitle": "Converte planilhas Excel (XLSX, XLS, ODS, CSV) para PDF. Suporta múltiplos arquivos.",
"acceptedFormats": "XLSX, XLS, ODS, CSV files", "acceptedFormats": "Arquivos XLSX, XLS, ODS, CSV",
"convertButton": "Convert to PDF" "convertButton": "Converter para PDF"
}, },
"powerpointToPdf": { "powerpointToPdf": {
"name": "PowerPoint to PDF", "name": "PowerPoint para PDF",
"subtitle": "Convert PowerPoint presentations (PPTX, PPT, ODP) to PDF format. Supports multiple files.", "subtitle": "Converte apresentações PowerPoint (PPTX, PPT, ODP) para PDF. Suporta múltiplos arquivos.",
"acceptedFormats": "PPTX, PPT, ODP files", "acceptedFormats": "Arquivos PPTX, PPT, ODP",
"convertButton": "Convert to PDF" "convertButton": "Converter para PDF"
}, },
"markdownToPdf": { "markdownToPdf": {
"name": "Markdown to PDF", "name": "Markdown para PDF",
"subtitle": "Write or paste Markdown and export it as a beautifully formatted PDF.", "subtitle": "Escreva ou cole Markdown e exporte como um belo formato PDF.",
"paneMarkdown": "Markdown", "paneMarkdown": "Markdown",
"panePreview": "Preview", "panePreview": "Pré-visualizar",
"btnUpload": "Upload", "btnUpload": "Upload",
"btnSyncScroll": "Sync Scroll", "btnSyncScroll": "Sincronizar Scroll",
"btnSettings": "Settings", "btnSettings": "Configurações",
"btnExportPdf": "Export PDF", "btnExportPdf": "Exportar PDF",
"settingsTitle": "Markdown Settings", "settingsTitle": "Configurações Markdown",
"settingsPreset": "Preset", "settingsPreset": "Predefinição",
"presetDefault": "Default (GFM-like)", "presetDefault": "Padrão (tipo GFM)",
"presetCommonmark": "CommonMark (strict)", "presetCommonmark": "CommonMark (extrito)",
"presetZero": "Minimal (no features)", "presetZero": "Mínimo (sem features)",
"settingsOptions": "Markdown Options", "settingsOptions": "Opções de Markdown",
"optAllowHtml": "Allow HTML tags", "optAllowHtml": "Permitir tags HTML",
"optBreaks": "Convert newlines to <br>", "optBreaks": "Converter novas linhas em <br>",
"optLinkify": "Auto-convert URLs to links", "optLinkify": "Auto-converter URLs para links",
"optTypographer": "Typographer (smart quotes, etc.)" "optTypographer": "Tipógrafo (aspas inteligentes, etc.)"
}, },
"pdfBooklet": { "pdfBooklet": {
"name": "PDF Booklet", "name": "PDF Booklet",
"subtitle": "Rearrange pages for double-sided booklet printing. Fold and staple to create a booklet.", "subtitle": "Reordena páginas para impressão de livreto frente e verso. Dobre e grampeie para criar um livreto.",
"howItWorks": "How it works:", "howItWorks": "Como funciona:",
"step1": "Upload a PDF file.", "step1": "Faça o upload de um arquivo PDF.",
"step2": "Pages will be rearranged in booklet order.", "step2": "As páginas serão reordenadas na ordem de livretos.",
"step3": "Print double-sided, flip on short edge, fold and staple.", "step3": "Imprima frente e verso, vire pelo topo, dobre e grampeie.",
"paperSize": "Paper Size", "paperSize": "Tamanho do Papel",
"orientation": "Orientation", "orientation": "Orientação",
"portrait": "Portrait", "portrait": "Retrato",
"landscape": "Landscape", "landscape": "Paisagem",
"pagesPerSheet": "Pages per Sheet", "pagesPerSheet": "Páginas por Folha",
"createBooklet": "Create Booklet", "createBooklet": "Criar Livreto",
"processing": "Processing...", "processing": "Processando...",
"pageCount": "Page count will be padded to multiple of 4 if needed." "pageCount": "A contagem de páginas será ajustada para um múltiplo de 4, caso necessário."
}, },
"xpsToPdf": { "xpsToPdf": {
"name": "XPS to PDF", "name": "XPS para PDF",
"subtitle": "Convert XPS/OXPS documents to PDF format. Supports multiple files.", "subtitle": "Converte XPS/OXPS para PDF. Suporta múltiplos arquivos.",
"acceptedFormats": "XPS, OXPS files", "acceptedFormats": "Arquivos XPS, OXPS",
"convertButton": "Convert to PDF" "convertButton": "Converter para PDF"
}, },
"mobiToPdf": { "mobiToPdf": {
"name": "MOBI to PDF", "name": "MOBI para PDF",
"subtitle": "Convert MOBI e-books to PDF format. Supports multiple files.", "subtitle": "Converte e-books MOBI para PDF. Suporta múltiplos arquivos.",
"acceptedFormats": "MOBI files", "acceptedFormats": "Arquivos MOBI",
"convertButton": "Convert to PDF" "convertButton": "Converter para PDF"
}, },
"epubToPdf": { "epubToPdf": {
"name": "EPUB to PDF", "name": "EPUB para PDF",
"subtitle": "Convert EPUB e-books to PDF format. Supports multiple files.", "subtitle": "Converte e-books EPUB para PDF. Suporta múltiplos arquivos.",
"acceptedFormats": "EPUB files", "acceptedFormats": "Arquivos EPUB",
"convertButton": "Convert to PDF" "convertButton": "Converter para PDF"
}, },
"fb2ToPdf": { "fb2ToPdf": {
"name": "FB2 to PDF", "name": "FB2 para PDF",
"subtitle": "Convert FictionBook (FB2) e-books to PDF format. Supports multiple files.", "subtitle": "Converte e-books FictionBook (FB2) para PDF. Suporta múltiplos arquivos.",
"acceptedFormats": "FB2 files", "acceptedFormats": "Arquivos FB2",
"convertButton": "Convert to PDF" "convertButton": "Converter para PDF"
}, },
"cbzToPdf": { "cbzToPdf": {
"name": "CBZ to PDF", "name": "CBZ para PDF",
"subtitle": "Convert comic book archives (CBZ/CBR) to PDF format. Supports multiple files.", "subtitle": "Converte comic book archives (CBZ/CBR) para PDF. Suporta múltiplos arquivos.",
"acceptedFormats": "CBZ, CBR files", "acceptedFormats": "Arquivos CBZ, CBR",
"convertButton": "Convert to PDF" "convertButton": "Converter para PDF"
}, },
"wpdToPdf": { "wpdToPdf": {
"name": "WPD to PDF", "name": "WPD para PDF",
"subtitle": "Convert WordPerfect documents (WPD) to PDF format. Supports multiple files.", "subtitle": "Converte WordPerfect (WPD) para PDF. Suporta múltiplos arquivos.",
"acceptedFormats": "WPD files", "acceptedFormats": "Arquivos WPD",
"convertButton": "Convert to PDF" "convertButton": "Converter para PDF"
}, },
"wpsToPdf": { "wpsToPdf": {
"name": "WPS to PDF", "name": "WPS para PDF",
"subtitle": "Convert WPS Office documents to PDF format. Supports multiple files.", "subtitle": "Converte WPS Office para PDF. Suporta múltiplos arquivos.",
"acceptedFormats": "WPS files", "acceptedFormats": "Arquivos WPS",
"convertButton": "Convert to PDF" "convertButton": "Converter para PDF"
}, },
"xmlToPdf": { "xmlToPdf": {
"name": "XML to PDF", "name": "XML para PDF",
"subtitle": "Convert XML documents to PDF format. Supports multiple files.", "subtitle": "Converte XML para PDF. Suporta múltiplos arquivos.",
"acceptedFormats": "XML files", "acceptedFormats": "Arquivos XML",
"convertButton": "Convert to PDF" "convertButton": "Converter para PDF"
}, },
"pagesToPdf": { "pagesToPdf": {
"name": "Pages to PDF", "name": "Pages para PDF",
"subtitle": "Convert Apple Pages documents to PDF format. Supports multiple files.", "subtitle": "Converte Apple Pages para PDF. Suporta múltiplos arquivos.",
"acceptedFormats": "Pages files", "acceptedFormats": "Arquivos Pages",
"convertButton": "Convert to PDF" "convertButton": "Converter para PDF"
}, },
"odgToPdf": { "odgToPdf": {
"name": "ODG to PDF", "name": "ODG para PDF",
"subtitle": "Convert OpenDocument Graphics (ODG) files to PDF format. Supports multiple files.", "subtitle": "Converte OpenDocument Graphics (ODG) para PDF. Suporta múltiplos arquivos.",
"acceptedFormats": "ODG files", "acceptedFormats": "Arquivos ODG",
"convertButton": "Convert to PDF" "convertButton": "Converter para PDF"
}, },
"odsToPdf": { "odsToPdf": {
"name": "ODS to PDF", "name": "ODS para PDF",
"subtitle": "Convert OpenDocument Spreadsheet (ODS) files to PDF format. Supports multiple files.", "subtitle": "Converte OpenDocument Spreadsheet (ODS) para PDF. Suporta múltiplos arquivos.",
"acceptedFormats": "ODS files", "acceptedFormats": "Arquivos ODS",
"convertButton": "Convert to PDF" "convertButton": "Converter para PDF"
}, },
"odpToPdf": { "odpToPdf": {
"name": "ODP to PDF", "name": "ODP para PDF",
"subtitle": "Convert OpenDocument Presentation (ODP) files to PDF format. Supports multiple files.", "subtitle": "Converte OpenDocument Presentation (ODP) para PDF. Suporta múltiplos arquivos.",
"acceptedFormats": "ODP files", "acceptedFormats": "Arquivos ODP",
"convertButton": "Convert to PDF" "convertButton": "Converter para PDF"
}, },
"pubToPdf": { "pubToPdf": {
"name": "PUB to PDF", "name": "PUB para PDF",
"subtitle": "Convert Microsoft Publisher (PUB) files to PDF format. Supports multiple files.", "subtitle": "Converte Microsoft Publisher (PUB) para PDF. Suporta múltiplos arquivos.",
"acceptedFormats": "PUB files", "acceptedFormats": "Arquivos PUB",
"convertButton": "Convert to PDF" "convertButton": "Converter para PDF"
}, },
"vsdToPdf": { "vsdToPdf": {
"name": "VSD to PDF", "name": "VSD para PDF",
"subtitle": "Convert Microsoft Visio (VSD, VSDX) files to PDF format. Supports multiple files.", "subtitle": "Converte Microsoft Visio (VSD, VSDX) para PDF. Suporta múltiplos arquivos.",
"acceptedFormats": "VSD, VSDX files", "acceptedFormats": "Arquivos VSD, VSDX",
"convertButton": "Convert to PDF" "convertButton": "Converter para PDF"
}, },
"psdToPdf": { "psdToPdf": {
"name": "PSD to PDF", "name": "PSD para PDF",
"subtitle": "Convert Adobe Photoshop (PSD) files to PDF format. Supports multiple files.", "subtitle": "Converte Adobe Photoshop (PSD) para PDF. Suporta múltiplos arquivos.",
"acceptedFormats": "PSD files", "acceptedFormats": "Arquivos PSD",
"convertButton": "Convert to PDF" "convertButton": "Converter para PDF"
}, },
"pdfToSvg": { "pdfToSvg": {
"name": "PDF to SVG", "name": "PDF para SVG",
"subtitle": "Convert each page of a PDF file into a scalable vector graphic (SVG) for perfect quality at any size." "subtitle": "Converte cada página de um arquivo PDF em um scalable vector graphic (SVG) para qualidade perfeita em qualquer tamanho."
}, },
"extractTables": { "extractTables": {
"name": "Extract PDF Tables", "name": "Extrair Tabelas PDF",
"subtitle": "Extract tables from PDF files and export as CSV, JSON, or Markdown." "subtitle": "Extrai tabelas de arquivos PDF e exporta como CSV, JSON, ou Markdown."
}, },
"pdfToCsv": { "pdfToCsv": {
"name": "PDF to CSV", "name": "PDF para CSV",
"subtitle": "Extract tables from PDF and convert to CSV format." "subtitle": "Extrai tabelas do PDF e converte em CSV."
}, },
"pdfToExcel": { "pdfToExcel": {
"name": "PDF to Excel", "name": "PDF para Excel",
"subtitle": "Extract tables from PDF and convert to Excel (XLSX) format." "subtitle": "Extrai tabelas do PDF e converte em Excel (XLSX)."
}, },
"pdfToText": { "pdfToText": {
"name": "PDF to Text", "name": "PDF para Texto",
"subtitle": "Extract text from PDF files and save as plain text (.txt). Supports multiple files.", "subtitle": "Extrai texto de arquivos PDF e salva como texto simples (.txt). Suporta múltiplos arquivos.",
"note": "This tool works ONLY with digitally created PDFs. For scanned documents or image-based PDFs, use our OCR PDF tool instead.", "note": "Esta ferrament funciona SOMENTE com PDFs criados digitalmente. Para documentos escaneados ou PDFs baseados em imagens, use nossa ferramenta OCR para PDF no lugar.",
"convertButton": "Extract Text" "convertButton": "Extrair Texto"
}, },
"digitalSignPdf": { "digitalSignPdf": {
"name": "Assinatura Digital PDF", "name": "Assinatura Digital PDF",
+4
View File
@@ -357,5 +357,9 @@
}, },
"relatedTools": { "relatedTools": {
"title": "İlgili PDF Araçları" "title": "İlgili PDF Araçları"
},
"simpleMode": {
"title": "PDF Araçları",
"subtitle": "Başlamak için bir araç seçin"
} }
} }
+6 -1
View File
@@ -64,9 +64,14 @@
"name": "Sayfa Numaraları", "name": "Sayfa Numaraları",
"subtitle": "Belgenize sayfa numaraları ekleyin." "subtitle": "Belgenize sayfa numaraları ekleyin."
}, },
"batesNumbering": {
"name": "Bates Numaralandırma",
"subtitle": "Bir veya daha fazla PDF dosyasına sıralı Bates numaraları ekleyin."
},
"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",
+4
View File
@@ -357,5 +357,9 @@
}, },
"relatedTools": { "relatedTools": {
"title": "Công cụ PDF liên quan" "title": "Công cụ PDF liên quan"
},
"simpleMode": {
"title": "Công cụ PDF",
"subtitle": "Chọn một công cụ để bắt đầu"
} }
} }
+6 -1
View File
@@ -86,9 +86,14 @@
"name": "Số trang", "name": "Số trang",
"subtitle": "Chèn số trang vào tài liệu của bạn." "subtitle": "Chèn số trang vào tài liệu của bạn."
}, },
"batesNumbering": {
"name": "Đánh số Bates",
"subtitle": "Thêm số Bates tuần tự trên một hoặc nhiều tệp PDF."
},
"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",
+4
View File
@@ -357,5 +357,9 @@
}, },
"relatedTools": { "relatedTools": {
"title": "相關 PDF 工具" "title": "相關 PDF 工具"
},
"simpleMode": {
"title": "PDF 工具",
"subtitle": "選擇一個工具開始使用"
} }
} }
+6 -1
View File
@@ -64,9 +64,14 @@
"name": "頁碼", "name": "頁碼",
"subtitle": "在你的文件中插入頁碼。" "subtitle": "在你的文件中插入頁碼。"
}, },
"batesNumbering": {
"name": "Bates編號",
"subtitle": "在一個或多個PDF檔案中新增連續的Bates編號。"
},
"addWatermark": { "addWatermark": {
"name": "添加浮水印", "name": "添加浮水印",
"subtitle": "在你的 PDF 頁面上壓印文字或圖片。" "subtitle": "在你的 PDF 頁面上壓印文字或圖片。",
"applyToAllPages": "套用至所有頁面"
}, },
"headerFooter": { "headerFooter": {
"name": "頁首與頁尾", "name": "頁首與頁尾",
+4
View File
@@ -357,5 +357,9 @@
}, },
"relatedTools": { "relatedTools": {
"title": "相关 PDF 工具" "title": "相关 PDF 工具"
},
"simpleMode": {
"title": "PDF 工具",
"subtitle": "选择一个工具开始使用"
} }
} }
+6 -1
View File
@@ -86,9 +86,14 @@
"name": "页码", "name": "页码",
"subtitle": "将页码插入到您的文档中。" "subtitle": "将页码插入到您的文档中。"
}, },
"batesNumbering": {
"name": "Bates编号",
"subtitle": "在一个或多个PDF文件中添加连续的Bates编号。"
},
"addWatermark": { "addWatermark": {
"name": "添加水印", "name": "添加水印",
"subtitle": "在您的 PDF 页面上添加文字或图片水印。" "subtitle": "在您的 PDF 页面上添加文字或图片水印。",
"applyToAllPages": "应用到所有页面"
}, },
"headerFooter": { "headerFooter": {
"name": "页眉和页脚", "name": "页眉和页脚",
+4 -1
View File
@@ -269,4 +269,7 @@ async function generateI18nPages() {
console.log('✅ i18n pages generated successfully!'); console.log('✅ i18n pages generated successfully!');
} }
generateI18nPages().catch(console.error); generateI18nPages().catch((err) => {
console.error('❌ i18n page generation failed:', err);
process.exit(1);
});
+755
View File
@@ -0,0 +1,755 @@
#!/usr/bin/env bash
set -euo pipefail
# ============================================================
# BentoPDF Air-Gapped Deployment Preparation Script
# ============================================================
# Automates the creation of a self-contained deployment bundle
# for air-gapped (offline) networks.
#
# Run this on a machine WITH internet access. The output bundle
# contains everything needed to deploy BentoPDF offline.
#
# Usage:
# bash scripts/prepare-airgap.sh --wasm-base-url https://internal.example.com/wasm
# bash scripts/prepare-airgap.sh # interactive mode
#
# See --help for all options.
# ============================================================
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PROJECT_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
# --- Output formatting ---
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
BLUE='\033[0;34m'
BOLD='\033[1m'
NC='\033[0m'
info() { echo -e "${BLUE}[INFO]${NC} $*"; }
success() { echo -e "${GREEN}[OK]${NC} $*"; }
warn() { echo -e "${YELLOW}[WARN]${NC} $*"; }
error() { echo -e "${RED}[ERR]${NC} $*" >&2; }
step() { echo -e "\n${BOLD}==> $*${NC}"; }
# Disable colors if NO_COLOR is set
if [ -n "${NO_COLOR:-}" ]; then
RED='' GREEN='' YELLOW='' BLUE='' BOLD='' NC=''
fi
# --- Defaults ---
WASM_BASE_URL=""
IMAGE_NAME="bentopdf"
OUTPUT_DIR="./bentopdf-airgap-bundle"
SIMPLE_MODE=""
BASE_URL=""
COMPRESSION_MODE=""
LANGUAGE=""
BRAND_NAME=""
BRAND_LOGO=""
FOOTER_TEXT=""
DOCKERFILE="Dockerfile"
SKIP_DOCKER=false
SKIP_WASM=false
INTERACTIVE=false
# --- Usage ---
usage() {
cat <<'EOF'
BentoPDF Air-Gapped Deployment Preparation
USAGE:
bash scripts/prepare-airgap.sh [OPTIONS]
bash scripts/prepare-airgap.sh # interactive mode
REQUIRED:
--wasm-base-url <url> Base URL where WASM files will be hosted
in the air-gapped network
(e.g. https://internal.example.com/wasm)
OPTIONS:
--image-name <name> Docker image name (default: bentopdf)
--output-dir <path> Output bundle directory (default: ./bentopdf-airgap-bundle)
--dockerfile <path> Dockerfile to use (default: Dockerfile)
--simple-mode Enable Simple Mode
--base-url <path> Subdirectory base URL (e.g. /pdf/)
--compression <mode> Compression: g, b, o, all (default: all)
--language <code> Default UI language (e.g. fr, de, es)
--brand-name <name> Custom brand name
--brand-logo <path> Logo path relative to public/
--footer-text <text> Custom footer text
--skip-docker Skip Docker build and export
--skip-wasm Skip WASM download (reuse existing .tgz files)
--help Show this help message
EXAMPLES:
# Minimal (prompts for WASM URL interactively)
bash scripts/prepare-airgap.sh
# Full automation
bash scripts/prepare-airgap.sh \
--wasm-base-url https://internal.example.com/wasm \
--brand-name "AcmePDF" \
--language fr
# Skip Docker build (reuse existing image)
bash scripts/prepare-airgap.sh \
--wasm-base-url https://internal.example.com/wasm \
--skip-docker
EOF
exit 0
}
# --- Parse arguments ---
while [[ $# -gt 0 ]]; do
case "$1" in
--wasm-base-url) WASM_BASE_URL="$2"; shift 2 ;;
--image-name) IMAGE_NAME="$2"; shift 2 ;;
--output-dir) OUTPUT_DIR="$2"; shift 2 ;;
--simple-mode) SIMPLE_MODE="true"; shift ;;
--base-url) BASE_URL="$2"; shift 2 ;;
--compression) COMPRESSION_MODE="$2"; shift 2 ;;
--language) LANGUAGE="$2"; shift 2 ;;
--brand-name) BRAND_NAME="$2"; shift 2 ;;
--brand-logo) BRAND_LOGO="$2"; shift 2 ;;
--footer-text) FOOTER_TEXT="$2"; shift 2 ;;
--dockerfile) DOCKERFILE="$2"; shift 2 ;;
--skip-docker) SKIP_DOCKER=true; shift ;;
--skip-wasm) SKIP_WASM=true; shift ;;
--help|-h) usage ;;
*) error "Unknown option: $1"; echo "Run with --help for usage."; exit 1 ;;
esac
done
# --- Validate project root ---
cd "$PROJECT_ROOT"
if [ ! -f "package.json" ] || [ ! -f "src/js/const/cdn-version.ts" ]; then
error "This script must be run from the BentoPDF project root."
error "Expected to find package.json and src/js/const/cdn-version.ts"
exit 1
fi
# --- Check prerequisites ---
check_prerequisites() {
local missing=false
if ! command -v npm &>/dev/null; then
error "npm is required but not found. Install Node.js first."
missing=true
fi
if [ "$SKIP_DOCKER" = false ] && ! command -v docker &>/dev/null; then
error "docker is required but not found (use --skip-docker to skip)."
missing=true
fi
if [ "$missing" = true ]; then
exit 1
fi
}
# --- Read versions from source code ---
read_versions() {
PYMUPDF_VERSION=$(grep "pymupdf:" src/js/const/cdn-version.ts | grep -o "'[^']*'" | tr -d "'")
GS_VERSION=$(grep "ghostscript:" src/js/const/cdn-version.ts | grep -o "'[^']*'" | tr -d "'")
APP_VERSION=$(node -p "require('./package.json').version")
if [ -z "$PYMUPDF_VERSION" ] || [ -z "$GS_VERSION" ]; then
error "Failed to read WASM versions from src/js/const/cdn-version.ts"
exit 1
fi
}
# --- Interactive mode ---
interactive_mode() {
echo ""
echo -e "${BOLD}============================================================${NC}"
echo -e "${BOLD} BentoPDF Air-Gapped Deployment Preparation${NC}"
echo -e "${BOLD} App Version: ${APP_VERSION}${NC}"
echo -e "${BOLD}============================================================${NC}"
echo ""
echo " Detected WASM versions from source:"
echo " PyMuPDF: ${PYMUPDF_VERSION}"
echo " Ghostscript: ${GS_VERSION}"
echo " CoherentPDF: latest"
echo ""
# [1] WASM base URL (REQUIRED)
echo -e "${BOLD}[1/8] WASM Base URL ${RED}(required)${NC}"
echo " The URL where WASM files will be hosted inside the air-gapped network."
echo " The script will append /pymupdf/, /gs/, /cpdf/ to this URL."
echo ""
echo " Examples:"
echo " https://internal.example.com/wasm"
echo " http://192.168.1.100/assets/wasm"
echo " https://cdn.mycompany.local/bentopdf"
echo ""
while true; do
read -r -p " URL: " WASM_BASE_URL
if [ -z "$WASM_BASE_URL" ]; then
warn "WASM base URL is required. Please enter a URL."
elif [[ ! "$WASM_BASE_URL" =~ ^https?:// ]]; then
warn "Must start with http:// or https://. Try again."
else
break
fi
done
echo ""
# [2] Docker image name (optional)
echo -e "${BOLD}[2/8] Docker Image Name ${GREEN}(optional)${NC}"
echo " The name used to tag the Docker image (used with 'docker run')."
read -r -p " Image name [${IMAGE_NAME}]: " input
IMAGE_NAME="${input:-$IMAGE_NAME}"
echo ""
# [3] Simple mode (optional)
echo -e "${BOLD}[3/8] Simple Mode ${GREEN}(optional)${NC}"
echo " Hides navigation, hero, features, FAQ — shows only PDF tools."
read -r -p " Enable Simple Mode? (y/N): " input
if [[ "${input:-}" =~ ^[Yy]$ ]]; then
SIMPLE_MODE="true"
fi
echo ""
# [4] Default language (optional)
echo -e "${BOLD}[4/8] Default UI Language ${GREEN}(optional)${NC}"
echo " Supported: en, ar, be, da, de, es, fr, id, it, nl, pt, tr, vi, zh, zh-TW"
while true; do
read -r -p " Language [en]: " input
LANGUAGE="${input:-}"
if [ -z "$LANGUAGE" ] || echo " en ar be da de es fr id it nl pt tr vi zh zh-TW " | grep -q " $LANGUAGE "; then
break
fi
warn "Invalid language code '${LANGUAGE}'. Try again."
done
echo ""
# [5] Custom branding (optional)
echo -e "${BOLD}[5/8] Custom Branding ${GREEN}(optional)${NC}"
echo " Replace the default BentoPDF name, logo, and footer text."
read -r -p " Brand name [BentoPDF]: " input
BRAND_NAME="${input:-}"
if [ -n "$BRAND_NAME" ]; then
echo " Place your logo in the public/ folder before building."
read -r -p " Logo path relative to public/ [images/favicon-no-bg.svg]: " input
BRAND_LOGO="${input:-}"
read -r -p " Footer text [© 2026 BentoPDF. All rights reserved.]: " input
FOOTER_TEXT="${input:-}"
fi
echo ""
# [6] Base URL (optional)
echo -e "${BOLD}[6/8] Base URL ${GREEN}(optional)${NC}"
echo " Set this if hosting under a subdirectory (e.g. /pdf/)."
read -r -p " Base URL [/]: " input
BASE_URL="${input:-}"
echo ""
# [7] Dockerfile (optional)
echo -e "${BOLD}[7/8] Dockerfile ${GREEN}(optional)${NC}"
echo " Options: Dockerfile (standard) or Dockerfile.nonroot (custom PUID/PGID)"
read -r -p " Dockerfile [${DOCKERFILE}]: " input
DOCKERFILE="${input:-$DOCKERFILE}"
echo ""
# [8] Output directory (optional)
echo -e "${BOLD}[8/8] Output Directory ${GREEN}(optional)${NC}"
read -r -p " Path [${OUTPUT_DIR}]: " input
OUTPUT_DIR="${input:-$OUTPUT_DIR}"
# Confirm
echo ""
echo -e "${BOLD}--- Configuration Summary ---${NC}"
echo ""
echo " WASM Base URL: ${WASM_BASE_URL}"
echo " Image Name: ${IMAGE_NAME}"
echo " Dockerfile: ${DOCKERFILE}"
echo " Simple Mode: ${SIMPLE_MODE:-false}"
echo " Language: ${LANGUAGE:-en (default)}"
echo " Brand Name: ${BRAND_NAME:-BentoPDF (default)}"
[ -n "$BRAND_NAME" ] && echo " Brand Logo: ${BRAND_LOGO:-images/favicon-no-bg.svg (default)}"
[ -n "$BRAND_NAME" ] && echo " Footer Text: ${FOOTER_TEXT:-(default)}"
echo " Base URL: ${BASE_URL:-/ (root)}"
echo " Output: ${OUTPUT_DIR}"
echo ""
read -r -p " Proceed? (Y/n): " input
if [[ "${input:-Y}" =~ ^[Nn]$ ]]; then
echo "Aborted."
exit 0
fi
}
# --- SHA-256 checksum (cross-platform) ---
sha256() {
if command -v sha256sum &>/dev/null; then
sha256sum "$1" | awk '{print $1}'
elif command -v shasum &>/dev/null; then
shasum -a 256 "$1" | awk '{print $1}'
else
echo "n/a"
fi
}
# --- File size (human-readable, cross-platform) ---
filesize() {
if stat --version &>/dev/null 2>&1; then
# GNU stat (Linux)
stat --printf='%s' "$1" 2>/dev/null | awk '{
if ($1 >= 1073741824) printf "%.1f GB", $1/1073741824;
else if ($1 >= 1048576) printf "%.1f MB", $1/1048576;
else if ($1 >= 1024) printf "%.1f KB", $1/1024;
else printf "%d B", $1;
}'
else
# BSD stat (macOS)
stat -f '%z' "$1" 2>/dev/null | awk '{
if ($1 >= 1073741824) printf "%.1f GB", $1/1073741824;
else if ($1 >= 1048576) printf "%.1f MB", $1/1048576;
else if ($1 >= 1024) printf "%.1f KB", $1/1024;
else printf "%d B", $1;
}'
fi
}
# ============================================================
# MAIN
# ============================================================
check_prerequisites
read_versions
# If no WASM base URL provided, go interactive
if [ -z "$WASM_BASE_URL" ]; then
INTERACTIVE=true
interactive_mode
fi
# Validate language code if provided
if [ -n "$LANGUAGE" ]; then
VALID_LANGS="en ar be da de es fr id it nl pt tr vi zh zh-TW"
if ! echo " $VALID_LANGS " | grep -q " $LANGUAGE "; then
error "Invalid language code: ${LANGUAGE}"
error "Supported: ${VALID_LANGS}"
exit 1
fi
fi
# Validate WASM base URL format
if [[ ! "$WASM_BASE_URL" =~ ^https?:// ]]; then
error "WASM base URL must start with http:// or https://"
error " Got: ${WASM_BASE_URL}"
error " Example: https://internal.example.com/wasm"
exit 1
fi
# Strip trailing slash from WASM base URL
WASM_BASE_URL="${WASM_BASE_URL%/}"
# Construct WASM URLs
WASM_PYMUPDF_URL="${WASM_BASE_URL}/pymupdf/"
WASM_GS_URL="${WASM_BASE_URL}/gs/"
WASM_CPDF_URL="${WASM_BASE_URL}/cpdf/"
echo ""
echo -e "${BOLD}============================================================${NC}"
echo -e "${BOLD} BentoPDF Air-Gapped Bundle Preparation${NC}"
echo -e "${BOLD} App: v${APP_VERSION} | PyMuPDF: ${PYMUPDF_VERSION} | GS: ${GS_VERSION}${NC}"
echo -e "${BOLD}============================================================${NC}"
# --- Phase 1: Prepare output directory ---
step "Preparing output directory"
mkdir -p "$OUTPUT_DIR"
OUTPUT_DIR="$(cd "$OUTPUT_DIR" && pwd)"
# Warn if output directory already has bundle files
if ls "$OUTPUT_DIR"/*.tgz "$OUTPUT_DIR"/bentopdf.tar "$OUTPUT_DIR"/setup.sh 2>/dev/null | head -1 &>/dev/null; then
warn "Output directory already contains files from a previous run."
warn "Existing files will be overwritten."
if [ "$INTERACTIVE" = true ]; then
read -r -p " Continue? (Y/n): " input
if [[ "${input:-Y}" =~ ^[Nn]$ ]]; then
echo "Aborted."
exit 0
fi
fi
fi
info "Output: ${OUTPUT_DIR}"
# --- Phase 2: Download WASM packages ---
if [ "$SKIP_WASM" = true ]; then
step "Skipping WASM download (--skip-wasm)"
# Verify each file exists with specific errors
wasm_missing=false
if ! ls "$OUTPUT_DIR"/bentopdf-pymupdf-wasm-*.tgz &>/dev/null; then
error "Missing: bentopdf-pymupdf-wasm-*.tgz"
wasm_missing=true
fi
if ! ls "$OUTPUT_DIR"/bentopdf-gs-wasm-*.tgz &>/dev/null; then
error "Missing: bentopdf-gs-wasm-*.tgz"
wasm_missing=true
fi
if ! ls "$OUTPUT_DIR"/coherentpdf-*.tgz &>/dev/null; then
error "Missing: coherentpdf-*.tgz"
wasm_missing=true
fi
if [ "$wasm_missing" = true ]; then
error "Run without --skip-wasm first to download the packages."
exit 1
fi
success "Reusing existing WASM packages"
else
step "Downloading WASM packages"
WASM_TMP=$(mktemp -d)
trap 'rm -rf "$WASM_TMP"' EXIT
info "Downloading @bentopdf/pymupdf-wasm@${PYMUPDF_VERSION}..."
if ! (cd "$WASM_TMP" && npm pack "@bentopdf/pymupdf-wasm@${PYMUPDF_VERSION}" --quiet 2>&1); then
error "Failed to download @bentopdf/pymupdf-wasm@${PYMUPDF_VERSION}"
error "Check your internet connection and that the package exists on npm."
exit 1
fi
info "Downloading @bentopdf/gs-wasm@${GS_VERSION}..."
if ! (cd "$WASM_TMP" && npm pack "@bentopdf/gs-wasm@${GS_VERSION}" --quiet 2>&1); then
error "Failed to download @bentopdf/gs-wasm@${GS_VERSION}"
error "Check your internet connection and that the package exists on npm."
exit 1
fi
info "Downloading coherentpdf..."
if ! (cd "$WASM_TMP" && npm pack coherentpdf --quiet 2>&1); then
error "Failed to download coherentpdf"
error "Check your internet connection and that the package exists on npm."
exit 1
fi
# Move to output directory
mv "$WASM_TMP"/*.tgz "$OUTPUT_DIR/"
rm -rf "$WASM_TMP"
trap - EXIT
# Resolve CoherentPDF version from filename
CPDF_TGZ=$(ls "$OUTPUT_DIR"/coherentpdf-*.tgz 2>/dev/null | head -1)
CPDF_VERSION=$(basename "$CPDF_TGZ" | sed 's/coherentpdf-\(.*\)\.tgz/\1/')
success "Downloaded all WASM packages"
info " PyMuPDF: $(filesize "$OUTPUT_DIR"/bentopdf-pymupdf-wasm-*.tgz)"
info " Ghostscript: $(filesize "$OUTPUT_DIR"/bentopdf-gs-wasm-*.tgz)"
info " CoherentPDF: $(filesize "$CPDF_TGZ") (v${CPDF_VERSION})"
fi
# Resolve CPDF version if we skipped download
if [ -z "${CPDF_VERSION:-}" ]; then
CPDF_TGZ=$(ls "$OUTPUT_DIR"/coherentpdf-*.tgz 2>/dev/null | head -1)
CPDF_VERSION=$(basename "$CPDF_TGZ" | sed 's/coherentpdf-\(.*\)\.tgz/\1/')
fi
# --- Phase 3: Build Docker image ---
if [ "$SKIP_DOCKER" = true ]; then
step "Skipping Docker build (--skip-docker)"
# Check if image exists or tar exists
if [ -f "$OUTPUT_DIR/bentopdf.tar" ]; then
success "Reusing existing bentopdf.tar"
elif docker image inspect "$IMAGE_NAME" &>/dev/null; then
step "Exporting existing Docker image"
docker save "$IMAGE_NAME" -o "$OUTPUT_DIR/bentopdf.tar"
success "Exported: $(filesize "$OUTPUT_DIR/bentopdf.tar")"
else
warn "No Docker image '${IMAGE_NAME}' found and no bentopdf.tar in output."
warn "The bundle will not include a Docker image."
fi
else
step "Building Docker image"
# Verify Dockerfile exists
if [ ! -f "$DOCKERFILE" ]; then
error "Dockerfile not found: ${DOCKERFILE}"
error "Available Dockerfiles:"
ls -1 Dockerfile* 2>/dev/null | sed 's/^/ /' || echo " (none found)"
exit 1
fi
# Verify Docker daemon is running
if ! docker info &>/dev/null; then
error "Docker daemon is not running. Start Docker and try again."
exit 1
fi
# Build the docker build command
BUILD_ARGS=()
BUILD_ARGS+=(--build-arg "VITE_WASM_PYMUPDF_URL=${WASM_PYMUPDF_URL}")
BUILD_ARGS+=(--build-arg "VITE_WASM_GS_URL=${WASM_GS_URL}")
BUILD_ARGS+=(--build-arg "VITE_WASM_CPDF_URL=${WASM_CPDF_URL}")
[ -n "$SIMPLE_MODE" ] && BUILD_ARGS+=(--build-arg "SIMPLE_MODE=${SIMPLE_MODE}")
[ -n "$BASE_URL" ] && BUILD_ARGS+=(--build-arg "BASE_URL=${BASE_URL}")
[ -n "$COMPRESSION_MODE" ] && BUILD_ARGS+=(--build-arg "COMPRESSION_MODE=${COMPRESSION_MODE}")
[ -n "$LANGUAGE" ] && BUILD_ARGS+=(--build-arg "VITE_DEFAULT_LANGUAGE=${LANGUAGE}")
[ -n "$BRAND_NAME" ] && BUILD_ARGS+=(--build-arg "VITE_BRAND_NAME=${BRAND_NAME}")
[ -n "$BRAND_LOGO" ] && BUILD_ARGS+=(--build-arg "VITE_BRAND_LOGO=${BRAND_LOGO}")
[ -n "$FOOTER_TEXT" ] && BUILD_ARGS+=(--build-arg "VITE_FOOTER_TEXT=${FOOTER_TEXT}")
info "Image name: ${IMAGE_NAME}"
info "Dockerfile: ${DOCKERFILE}"
info "WASM URLs:"
info " PyMuPDF: ${WASM_PYMUPDF_URL}"
info " Ghostscript: ${WASM_GS_URL}"
info " CoherentPDF: ${WASM_CPDF_URL}"
echo ""
info "Building... this may take a few minutes (npm install + Vite build)."
echo ""
docker build -f "$DOCKERFILE" "${BUILD_ARGS[@]}" -t "$IMAGE_NAME" .
success "Docker image '${IMAGE_NAME}' built successfully"
# --- Phase 4: Export Docker image ---
step "Exporting Docker image"
docker save "$IMAGE_NAME" -o "$OUTPUT_DIR/bentopdf.tar"
success "Exported: $(filesize "$OUTPUT_DIR/bentopdf.tar")"
fi
# --- Phase 5: Generate setup.sh ---
step "Generating setup script"
cat > "$OUTPUT_DIR/setup.sh" <<SETUP_EOF
#!/usr/bin/env bash
set -euo pipefail
# ============================================================
# BentoPDF Air-Gapped Setup Script
# Generated on $(date -u +"%Y-%m-%d %H:%M:%S UTC")
# BentoPDF v${APP_VERSION}
# ============================================================
# Transfer this entire directory to the air-gapped network,
# then run this script.
# ============================================================
SCRIPT_DIR="\$(cd "\$(dirname "\${BASH_SOURCE[0]}")" && pwd)"
# --- Check prerequisites ---
if ! command -v docker &>/dev/null; then
echo "ERROR: docker is required but not found."
echo "Install Docker first: https://docs.docker.com/get-docker/"
exit 1
fi
if ! docker info &>/dev/null; then
echo "ERROR: Docker daemon is not running. Start Docker and try again."
exit 1
fi
# --- Configuration (baked in at generation time) ---
IMAGE_NAME="${IMAGE_NAME}"
WASM_BASE_URL="${WASM_BASE_URL}"
DOCKER_PORT="\${1:-3000}"
# Where to extract WASM files (override with WASM_EXTRACT_DIR env var)
WASM_DIR="\${WASM_EXTRACT_DIR:-\${SCRIPT_DIR}/wasm}"
echo ""
echo "============================================================"
echo " BentoPDF Air-Gapped Setup"
echo " Version: ${APP_VERSION}"
echo "============================================================"
echo ""
echo " Docker image: \${IMAGE_NAME}"
echo " WASM base URL: \${WASM_BASE_URL}"
echo " WASM extract: \${WASM_DIR}"
echo " Port: \${DOCKER_PORT}"
echo ""
# --- Step 1: Load Docker Image ---
echo "[1/3] Loading Docker image..."
if [ -f "\${SCRIPT_DIR}/bentopdf.tar" ]; then
docker load -i "\${SCRIPT_DIR}/bentopdf.tar"
echo " Docker image '\${IMAGE_NAME}' loaded."
else
echo " WARNING: bentopdf.tar not found. Skipping Docker load."
echo " Make sure the image '\${IMAGE_NAME}' is already available."
fi
# --- Step 2: Extract WASM Packages ---
echo ""
echo "[2/3] Extracting WASM packages to \${WASM_DIR}..."
mkdir -p "\${WASM_DIR}/pymupdf" "\${WASM_DIR}/gs" "\${WASM_DIR}/cpdf"
# PyMuPDF: package has dist/ and assets/ at root
echo " Extracting PyMuPDF..."
tar xzf "\${SCRIPT_DIR}"/bentopdf-pymupdf-wasm-*.tgz -C "\${WASM_DIR}/pymupdf" --strip-components=1
# Ghostscript: browser expects gs.js and gs.wasm at root
echo " Extracting Ghostscript..."
TEMP_GS="\$(mktemp -d)"
tar xzf "\${SCRIPT_DIR}"/bentopdf-gs-wasm-*.tgz -C "\${TEMP_GS}"
if [ -d "\${TEMP_GS}/package/assets" ]; then
cp -r "\${TEMP_GS}/package/assets/"* "\${WASM_DIR}/gs/"
else
cp -r "\${TEMP_GS}/package/"* "\${WASM_DIR}/gs/"
fi
rm -rf "\${TEMP_GS}"
# CoherentPDF: browser expects coherentpdf.browser.min.js at root
echo " Extracting CoherentPDF..."
TEMP_CPDF="\$(mktemp -d)"
tar xzf "\${SCRIPT_DIR}"/coherentpdf-*.tgz -C "\${TEMP_CPDF}"
if [ -d "\${TEMP_CPDF}/package/dist" ]; then
cp -r "\${TEMP_CPDF}/package/dist/"* "\${WASM_DIR}/cpdf/"
else
cp -r "\${TEMP_CPDF}/package/"* "\${WASM_DIR}/cpdf/"
fi
rm -rf "\${TEMP_CPDF}"
echo " WASM files extracted to: \${WASM_DIR}"
echo ""
echo " IMPORTANT: Ensure these paths are served by your internal web server:"
echo " \${WASM_BASE_URL}/pymupdf/ -> \${WASM_DIR}/pymupdf/"
echo " \${WASM_BASE_URL}/gs/ -> \${WASM_DIR}/gs/"
echo " \${WASM_BASE_URL}/cpdf/ -> \${WASM_DIR}/cpdf/"
# --- Step 3: Start BentoPDF ---
echo ""
echo "[3/3] Ready to start BentoPDF"
echo ""
echo " To start manually:"
echo " docker run -d --name bentopdf -p \${DOCKER_PORT}:8080 --restart unless-stopped \${IMAGE_NAME}"
echo ""
echo " Then open: http://localhost:\${DOCKER_PORT}"
echo ""
read -r -p "Start BentoPDF now? (y/N): " REPLY
if [[ "\${REPLY:-}" =~ ^[Yy]$ ]]; then
docker run -d --name bentopdf -p "\${DOCKER_PORT}:8080" --restart unless-stopped "\${IMAGE_NAME}"
echo ""
echo " BentoPDF is running at http://localhost:\${DOCKER_PORT}"
fi
SETUP_EOF
chmod +x "$OUTPUT_DIR/setup.sh"
success "Generated setup.sh"
# --- Phase 6: Generate README ---
step "Generating README"
cat > "$OUTPUT_DIR/README.md" <<README_EOF
# BentoPDF Air-Gapped Deployment Bundle
**BentoPDF v${APP_VERSION}** | Generated on $(date -u +"%Y-%m-%d %H:%M:%S UTC")
## Contents
| File | Description |
| --- | --- |
| \`bentopdf.tar\` | Docker image |
| \`bentopdf-pymupdf-wasm-${PYMUPDF_VERSION}.tgz\` | PyMuPDF WASM module |
| \`bentopdf-gs-wasm-${GS_VERSION}.tgz\` | Ghostscript WASM module |
| \`coherentpdf-${CPDF_VERSION}.tgz\` | CoherentPDF WASM module |
| \`setup.sh\` | Automated setup script |
| \`README.md\` | This file |
## WASM Configuration
The Docker image was built with these WASM URLs:
- **PyMuPDF:** \`${WASM_PYMUPDF_URL}\`
- **Ghostscript:** \`${WASM_GS_URL}\`
- **CoherentPDF:** \`${WASM_CPDF_URL}\`
These URLs are baked into the app at build time. The user's browser fetches
WASM files from these URLs at runtime.
## Quick Setup
Transfer this entire directory to the air-gapped network, then:
\`\`\`bash
bash setup.sh
\`\`\`
The setup script will:
1. Load the Docker image
2. Extract WASM packages to \`./wasm/\` (override with \`WASM_EXTRACT_DIR\`)
3. Optionally start the BentoPDF container
## Manual Setup
### 1. Load the Docker image
\`\`\`bash
docker load -i bentopdf.tar
\`\`\`
### 2. Extract WASM packages
Extract to your internal web server's document root:
\`\`\`bash
mkdir -p ./wasm/pymupdf ./wasm/gs ./wasm/cpdf
# PyMuPDF
tar xzf bentopdf-pymupdf-wasm-${PYMUPDF_VERSION}.tgz -C ./wasm/pymupdf --strip-components=1
# Ghostscript (extract assets/ to root)
TEMP_GS=\$(mktemp -d)
tar xzf bentopdf-gs-wasm-${GS_VERSION}.tgz -C \$TEMP_GS
cp -r \$TEMP_GS/package/assets/* ./wasm/gs/
rm -rf \$TEMP_GS
# CoherentPDF (extract dist/ to root)
TEMP_CPDF=\$(mktemp -d)
tar xzf coherentpdf-${CPDF_VERSION}.tgz -C \$TEMP_CPDF
cp -r \$TEMP_CPDF/package/dist/* ./wasm/cpdf/
rm -rf \$TEMP_CPDF
\`\`\`
### 3. Configure your web server
Ensure these paths are accessible at the configured URLs:
| URL | Serves From |
| --- | --- |
| \`${WASM_PYMUPDF_URL}\` | \`./wasm/pymupdf/\` |
| \`${WASM_GS_URL}\` | \`./wasm/gs/\` |
| \`${WASM_CPDF_URL}\` | \`./wasm/cpdf/\` |
### 4. Run BentoPDF
\`\`\`bash
docker run -d --name bentopdf -p 3000:8080 --restart unless-stopped ${IMAGE_NAME}
\`\`\`
Open: http://localhost:3000
README_EOF
success "Generated README.md"
# --- Phase 7: Summary ---
step "Bundle complete"
echo ""
echo -e "${BOLD} Output: ${OUTPUT_DIR}${NC}"
echo ""
echo " Files:"
for f in "$OUTPUT_DIR"/*; do
fname=$(basename "$f")
fsize=$(filesize "$f")
echo " ${fname} (${fsize})"
done
echo ""
echo -e "${BOLD} Next steps:${NC}"
echo " 1. Transfer the '$(basename "$OUTPUT_DIR")' directory to the air-gapped network"
echo " 2. Run: bash setup.sh"
echo " 3. Configure your internal web server to serve the WASM files"
echo ""
success "Done!"
+24
View File
@@ -311,6 +311,30 @@
"created_at": "2026-02-03T13:50:03Z", "created_at": "2026-02-03T13:50:03Z",
"repoId": 1074785178, "repoId": 1074785178,
"pullRequestNo": 461 "pullRequestNo": 461
},
{
"name": "tzabbi",
"id": 40226087,
"comment_id": 3926638030,
"created_at": "2026-02-19T11:27:36Z",
"repoId": 1074785178,
"pullRequestNo": 493
},
{
"name": "diogotcorreia",
"id": 7467891,
"comment_id": 3937759209,
"created_at": "2026-02-21T00:26:29Z",
"repoId": 1074785178,
"pullRequestNo": 501
},
{
"name": "hagibr",
"id": 23699887,
"comment_id": 3960964454,
"created_at": "2026-02-25T17:50:30Z",
"repoId": 1074785178,
"pullRequestNo": 516
} }
] ]
} }
+44 -7
View File
@@ -56,12 +56,14 @@
<div class="flex justify-start items-center h-16"> <div class="flex justify-start items-center h-16">
<div class="flex-shrink-0 flex items-center cursor-pointer"> <div class="flex-shrink-0 flex items-center cursor-pointer">
<img <img
src="/images/favicon-no-bg.svg" src="{{baseUrl}}{{#if brandLogo}}{{brandLogo}}{{else}}images/favicon-no-bg.svg{{/if}}"
alt="Bento PDF Logo" alt="{{#if brandName}}{{brandName}}{{else}}Bento PDF{{/if}} Logo"
class="h-8 w-8" class="h-8 w-8"
/> />
<span class="text-white font-bold text-xl ml-2"> <span class="text-white font-bold text-xl ml-2">
<a href="/">BentoPDF</a> <a href="/"
>{{#if brandName}}{{brandName}}{{else}}BentoPDF{{/if}}</a
>
</span> </span>
</div> </div>
</div> </div>
@@ -330,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"
@@ -460,14 +494,17 @@
<div> <div>
<div class="flex items-center mb-2"> <div class="flex items-center mb-2">
<img <img
src="/images/favicon.svg" src="{{baseUrl}}{{#if brandLogo}}{{brandLogo}}{{else}}images/favicon.svg{{/if}}"
alt="Bento PDF Logo" alt="{{#if brandName}}{{brandName}}{{else}}Bento PDF{{/if}} Logo"
class="h-8 w-8 mr-2" class="h-8 w-8 mr-2"
/> />
<span class="text-white font-bold text-lg">BentoPDF</span> <span class="text-white font-bold text-lg"
>{{#if brandName}}{{brandName}}{{else}}BentoPDF{{/if}}</span
>
</div> </div>
<p class="text-gray-400 text-sm"> <p class="text-gray-400 text-sm">
&copy; 2026 BentoPDF. All rights reserved. {{#if footerText}}{{footerText}}{{else}}&copy; 2026 BentoPDF. All
rights reserved.{{/if}}
</p> </p>
<p class="text-gray-500 text-xs mt-2"> <p class="text-gray-500 text-xs mt-2">
Version <span id="app-version-simple"></span> Version <span id="app-version-simple"></span>
+147 -14
View File
@@ -76,6 +76,106 @@ body {
border-color: #4f46e5; border-color: #4f46e5;
} }
.category-header {
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
padding: 0.5rem 0;
margin-top: 2rem;
margin-bottom: 1rem;
background: none;
border: none;
cursor: pointer;
font-size: 1.25rem;
font-weight: 700;
color: #ffffff;
text-align: left;
}
.category-group:first-child .category-header {
margin-top: 0;
}
.category-header:hover {
color: #818cf8;
}
.category-header:hover .category-chevron {
color: #818cf8;
}
.category-tools {
overflow: hidden;
transition: max-height 300ms ease-in-out;
}
.category-group.collapsed .category-chevron {
transform: rotate(-90deg);
}
.compact-mode .category-tools {
grid-template-columns: 1fr !important;
gap: 0 !important;
}
.compact-mode .tool-card {
flex-direction: row;
padding: 0.75rem 1rem;
border-radius: 0;
border-left: none;
border-right: none;
border-top: none;
border-bottom: 1px solid #1f2937;
background-color: transparent;
align-items: center;
justify-content: flex-start;
text-align: left;
}
.compact-mode .tool-card i {
width: 1.25rem;
height: 1.25rem;
min-width: 1.25rem;
margin-bottom: 0;
margin-right: 0.75rem;
font-size: 1.25rem;
}
.compact-mode .tool-card i.ph {
font-size: 1.25rem;
margin-bottom: 0;
margin-right: 0.75rem;
}
.compact-mode .tool-card h3 {
font-size: 0.875rem;
font-weight: 500;
}
.compact-mode .tool-card p {
display: none;
}
.compact-mode .tool-card:hover {
transform: none;
box-shadow: none;
background-color: #1f2937;
border-color: #1f2937;
}
@media (min-width: 640px) {
.compact-mode .category-tools {
grid-template-columns: repeat(2, 1fr) !important;
}
}
@media (min-width: 1024px) {
.compact-mode .category-tools {
grid-template-columns: repeat(3, 1fr) !important;
}
}
.btn { .btn {
transition: transition:
background-color 0.2s ease-in-out, background-color 0.2s ease-in-out,
@@ -116,7 +216,7 @@ input[type='file']::file-selector-button {
border: 2px dashed #4f46e5; border: 2px dashed #4f46e5;
} }
#embed-pdf-container>div { #embed-pdf-container > div {
width: 100%; width: 100%;
height: 100%; height: 100%;
} }
@@ -126,7 +226,7 @@ input[type='file']::file-selector-button {
} }
.page-thumbnail, .page-thumbnail,
#file-list>li { #file-list > li {
cursor: grab; cursor: grab;
} }
@@ -264,8 +364,6 @@ footer a {
z-index: -1; z-index: -1;
} }
.section-divider { .section-divider {
height: 1px; height: 1px;
background: linear-gradient(to right, transparent, #4d44f7, transparent); background: linear-gradient(to right, transparent, #4d44f7, transparent);
@@ -501,19 +599,19 @@ footer a {
color: rgb(165 180 252); color: rgb(165 180 252);
} }
details>summary { details > summary {
list-style: none; list-style: none;
} }
details>summary::-webkit-details-marker { details > summary::-webkit-details-marker {
display: none; display: none;
} }
details>summary .icon { details > summary .icon {
transition: transform 0.2s ease-in-out; transition: transform 0.2s ease-in-out;
} }
details[open]>summary .icon { details[open] > summary .icon {
transform: rotate(45deg); transform: rotate(45deg);
} }
@@ -537,7 +635,9 @@ button:disabled,
height: 1.5rem; height: 1.5rem;
padding: 0 0.25rem; padding: 0 0.25rem;
font-size: 0.75rem; font-size: 0.75rem;
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; font-family:
ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono',
'Courier New', monospace;
line-height: 1; line-height: 1;
color: #e5e7eb; color: #e5e7eb;
background-color: #374151; background-color: #374151;
@@ -547,7 +647,9 @@ button:disabled,
} }
.shortcut-input { .shortcut-input {
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; font-family:
ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono',
'Courier New', monospace;
letter-spacing: 0.05em; letter-spacing: 0.05em;
} }
@@ -591,18 +693,49 @@ button:disabled,
linear-gradient(to right, rgba(255, 255, 255, 0.05) 1px, transparent 1px), linear-gradient(to right, rgba(255, 255, 255, 0.05) 1px, transparent 1px),
linear-gradient(to bottom, rgba(255, 255, 255, 0.05) 1px, transparent 1px); linear-gradient(to bottom, rgba(255, 255, 255, 0.05) 1px, transparent 1px);
mask-image: radial-gradient(ellipse at center, black 40%, transparent 80%); mask-image: radial-gradient(ellipse at center, black 40%, transparent 80%);
-webkit-mask-image: radial-gradient(ellipse at center, black 40%, transparent 80%); -webkit-mask-image: radial-gradient(
ellipse at center,
black 40%,
transparent 80%
);
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;
-moz-appearance: textfield; /* Firefox */ -moz-appearance: textfield; /* Firefox */
} }
input[type="number"]::-webkit-inner-spin-button, input[type='number']::-webkit-inner-spin-button,
input[type="number"]::-webkit-outer-spin-button { input[type='number']::-webkit-outer-spin-button {
-webkit-appearance: none; /* Chrome, Safari, Edge */ -webkit-appearance: none; /* Chrome, Safari, Edge */
margin: 0; margin: 0;
} }
+6
View File
@@ -108,6 +108,12 @@ export const categories = [
icon: 'ph-list-numbers', icon: 'ph-list-numbers',
subtitle: 'Insert page numbers into your document.', subtitle: 'Insert page numbers into your document.',
}, },
{
href: import.meta.env.BASE_URL + 'bates-numbering.html',
name: 'Bates Numbering',
icon: 'ph-hash',
subtitle: 'Add sequential Bates numbers across one or more PDF files.',
},
{ {
href: import.meta.env.BASE_URL + 'add-watermark.html', href: import.meta.env.BASE_URL + 'add-watermark.html',
name: 'Add Watermark', name: 'Add Watermark',
+1 -1
View File
@@ -1,4 +1,4 @@
export const PACKAGE_VERSIONS = { export const PACKAGE_VERSIONS = {
ghostscript: '0.1.1', ghostscript: '0.1.1',
pymupdf: '0.11.14', pymupdf: '0.11.16',
} as const; } as const;
+5
View File
@@ -69,6 +69,11 @@ export const getLanguageFromUrl = (): SupportedLanguage => {
return storedLang as SupportedLanguage; return storedLang as SupportedLanguage;
} }
const envLang = import.meta.env.VITE_DEFAULT_LANGUAGE;
if (envLang && supportedLanguages.includes(envLang as SupportedLanguage)) {
return envLang as SupportedLanguage;
}
return 'en'; return 'en';
}; };
File diff suppressed because it is too large Load Diff
+548
View File
@@ -0,0 +1,548 @@
import { createIcons, icons } from 'lucide';
import { showAlert, showLoader, hideLoader } from '../ui.js';
import { downloadFile, hexToRgb, formatBytes } from '../utils/helpers.js';
import { PDFDocument, StandardFonts, rgb } from 'pdf-lib';
import JSZip from 'jszip';
import Sortable from 'sortablejs';
import { FileEntry, Position, StylePreset } from '@/types';
const FONT_MAP: Record<string, keyof typeof StandardFonts> = {
Helvetica: 'Helvetica',
TimesRoman: 'TimesRoman',
Courier: 'Courier',
};
const STYLE_PRESETS: Record<string, StylePreset> = {
'full-6': {
template: 'Exhibit [FILE] Case XYZ [BATES] Page [PAGE]',
padding: 6,
},
'full-5': {
template: 'Exhibit [FILE] Case XYZ [BATES] Page [PAGE]',
padding: 5,
},
'full-4': {
template: 'Exhibit [FILE] Case XYZ [BATES] Page [PAGE]',
padding: 4,
},
'full-3': {
template: 'Exhibit [FILE] Case XYZ [BATES] Page [PAGE]',
padding: 3,
},
'full-0': {
template: 'Exhibit [FILE] Case XYZ [BATES] Page [PAGE]',
padding: 0,
},
'no-page-6': { template: 'Exhibit [FILE] Case XYZ [BATES]', padding: 6 },
'no-page-5': { template: 'Exhibit [FILE] Case XYZ [BATES]', padding: 5 },
'no-page-4': { template: 'Exhibit [FILE] Case XYZ [BATES]', padding: 4 },
'no-page-3': { template: 'Exhibit [FILE] Case XYZ [BATES]', padding: 3 },
'no-page-0': { template: 'Exhibit [FILE] Case XYZ [BATES]', padding: 0 },
'case-6': { template: 'Case XYZ [BATES]', padding: 6 },
'case-5': { template: 'Case XYZ [BATES]', padding: 5 },
'case-4': { template: 'Case XYZ [BATES]', padding: 4 },
'case-3': { template: 'Case XYZ [BATES]', padding: 3 },
'case-0': { template: 'Case XYZ [BATES]', padding: 0 },
'bates-6': { template: '[BATES]', padding: 6 },
'bates-5': { template: '[BATES]', padding: 5 },
'bates-4': { template: '[BATES]', padding: 4 },
'bates-3': { template: '[BATES]', padding: 3 },
'bates-0': { template: '[BATES]', padding: 0 },
};
const files: FileEntry[] = [];
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', initializePage);
} else {
initializePage();
}
function initializePage() {
createIcons({ icons });
const fileInput = document.getElementById('file-input') as HTMLInputElement;
const dropZone = document.getElementById('drop-zone');
const backBtn = document.getElementById('back-to-tools');
const processBtn = document.getElementById('process-btn');
const addMoreBtn = document.getElementById('add-more-btn');
const clearFilesBtn = document.getElementById('clear-files-btn');
const stylePreset = document.getElementById(
'style-preset'
) as HTMLSelectElement;
const templateInput = document.getElementById(
'bates-template'
) as HTMLInputElement;
if (fileInput) {
fileInput.addEventListener('change', () => {
if (fileInput.files?.length) {
handleFiles(fileInput.files);
fileInput.value = '';
}
});
}
if (dropZone) {
dropZone.addEventListener('dragover', (e) => {
e.preventDefault();
dropZone.classList.add('border-indigo-500');
});
dropZone.addEventListener('dragleave', () => {
dropZone.classList.remove('border-indigo-500');
});
dropZone.addEventListener('drop', (e) => {
e.preventDefault();
dropZone.classList.remove('border-indigo-500');
if (e.dataTransfer?.files.length) handleFiles(e.dataTransfer.files);
});
}
if (backBtn) {
backBtn.addEventListener('click', () => {
window.location.href = import.meta.env.BASE_URL;
});
}
if (processBtn) {
processBtn.addEventListener('click', applyBatesNumbers);
}
if (addMoreBtn) {
addMoreBtn.addEventListener('click', () => fileInput?.click());
}
if (clearFilesBtn) {
clearFilesBtn.addEventListener('click', resetState);
}
if (stylePreset) {
stylePreset.addEventListener('change', () => {
const value = stylePreset.value;
const isCustom = value === 'custom';
const paddingGroup = document.getElementById('padding-group');
if (!isCustom && STYLE_PRESETS[value]) {
templateInput.value = STYLE_PRESETS[value].template;
if (paddingGroup) paddingGroup.classList.add('hidden');
} else {
if (paddingGroup) paddingGroup.classList.remove('hidden');
}
templateInput.readOnly = !isCustom;
updatePreview();
});
}
if (templateInput) {
templateInput.addEventListener('input', () => {
const preset = stylePreset;
if (preset && preset.value !== 'custom') {
preset.value = 'custom';
templateInput.readOnly = false;
document.getElementById('padding-group')?.classList.remove('hidden');
}
updatePreview();
});
}
document
.getElementById('bates-padding')
?.addEventListener('change', updatePreview);
document
.getElementById('bates-start')
?.addEventListener('input', updatePreview);
document
.getElementById('file-start')
?.addEventListener('input', updatePreview);
initSortable();
}
function initSortable() {
const fileList = document.getElementById('file-list');
if (!fileList) return;
Sortable.create(fileList, {
handle: '.drag-handle',
animation: 150,
onEnd: (evt) => {
if (evt.oldIndex !== undefined && evt.newIndex !== undefined) {
const [moved] = files.splice(evt.oldIndex, 1);
files.splice(evt.newIndex, 0, moved);
updatePreview();
}
},
});
}
async function handleFiles(fileList: FileList) {
showLoader('Loading PDFs...');
try {
for (const file of Array.from(fileList)) {
if (file.type !== 'application/pdf') continue;
const arrayBuffer = await file.arrayBuffer();
const pdfDoc = await PDFDocument.load(arrayBuffer);
files.push({ file, pageCount: pdfDoc.getPageCount() });
}
if (files.length === 0) {
showAlert('Invalid File', 'Please upload valid PDF files.');
return;
}
renderFileList();
document.getElementById('options-panel')?.classList.remove('hidden');
document.getElementById('file-controls')?.classList.remove('hidden');
updatePreview();
} catch (error) {
console.error(error);
showAlert('Error', 'Failed to load one or more PDF files.');
} finally {
hideLoader();
}
}
function renderFileList() {
const fileListEl = document.getElementById('file-list');
if (!fileListEl) return;
fileListEl.innerHTML = '';
let totalPages = 0;
files.forEach((entry, index) => {
totalPages += entry.pageCount;
const fileDiv = document.createElement('div');
fileDiv.className =
'flex items-center justify-between bg-gray-700 p-3 rounded-lg';
const leftSection = document.createElement('div');
leftSection.className = 'flex items-center gap-3 flex-1 min-w-0';
const dragHandle = document.createElement('i');
dragHandle.setAttribute('data-lucide', 'grip-vertical');
dragHandle.className =
'drag-handle w-4 h-4 text-gray-400 cursor-grab flex-shrink-0';
const infoContainer = document.createElement('div');
infoContainer.className = 'flex flex-col min-w-0';
const nameSpan = document.createElement('div');
nameSpan.className = 'truncate font-medium text-gray-200 text-sm';
nameSpan.textContent = entry.file.name;
const metaSpan = document.createElement('div');
metaSpan.className = 'text-xs text-gray-400';
metaSpan.textContent = `${formatBytes(entry.file.size)} \u2022 ${entry.pageCount} pages`;
infoContainer.append(nameSpan, metaSpan);
leftSection.append(dragHandle, infoContainer);
const removeBtn = document.createElement('button');
removeBtn.className = 'ml-4 text-red-400 hover:text-red-300 flex-shrink-0';
removeBtn.innerHTML = '<i data-lucide="trash-2" class="w-4 h-4"></i>';
removeBtn.onclick = () => {
files.splice(index, 1);
renderFileList();
updatePreview();
if (files.length === 0) resetState();
};
fileDiv.append(leftSection, removeBtn);
fileListEl.appendChild(fileDiv);
});
createIcons({ icons });
const summary = document.createElement('div');
summary.className = 'text-xs text-gray-400 mt-1';
summary.textContent = `${files.length} file${files.length !== 1 ? 's' : ''} \u2022 ${totalPages} total pages`;
fileListEl.appendChild(summary);
}
function formatBatesText(
template: string,
batesNum: number,
pageNum: number,
fileNum: number,
fileName: string,
padding: number
): string {
const batesStr =
padding > 0 ? String(batesNum).padStart(padding, '0') : String(batesNum);
return template
.replace(/\[BATES\]/g, batesStr)
.replace(/\[PAGE\]/g, String(pageNum))
.replace(/\[FILE\]/g, String(fileNum))
.replace(/\[FILENAME\]/g, fileName);
}
function getActivePadding(): number {
const presetValue = (
document.getElementById('style-preset') as HTMLSelectElement
).value;
if (presetValue !== 'custom' && STYLE_PRESETS[presetValue]) {
return STYLE_PRESETS[presetValue].padding;
}
return (
parseInt(
(document.getElementById('bates-padding') as HTMLSelectElement).value
) || 0
);
}
function updatePreview() {
const previewEl = document.getElementById('preview-content');
if (!previewEl) return;
const template = (
document.getElementById('bates-template') as HTMLInputElement
).value;
const padding = getActivePadding();
const batesStart =
parseInt(
(document.getElementById('bates-start') as HTMLInputElement).value
) || 1;
const fileStart =
parseInt(
(document.getElementById('file-start') as HTMLInputElement).value
) || 1;
const lines: string[] = [];
if (files.length === 0) {
lines.push(
formatBatesText(template, batesStart, 1, fileStart, 'document', padding)
);
lines.push(
formatBatesText(
template,
batesStart + 1,
2,
fileStart,
'document',
padding
)
);
} else {
let batesCounter = batesStart;
let fileCounter = fileStart;
for (const entry of files) {
const name = entry.file.name.replace(/\.pdf$/i, '');
lines.push(
`File ${fileCounter}, Page 1: ${formatBatesText(template, batesCounter, 1, fileCounter, name, padding)}`
);
if (entry.pageCount > 1) {
lines.push(
`File ${fileCounter}, Page 2: ${formatBatesText(template, batesCounter + 1, 2, fileCounter, name, padding)}`
);
}
batesCounter += entry.pageCount;
fileCounter++;
}
const lastEntry = files[files.length - 1];
const lastName = lastEntry.file.name.replace(/\.pdf$/i, '');
const lastBates = batesCounter - 1;
lines.push('...');
lines.push(
`File ${fileStart + files.length - 1}, Page ${lastEntry.pageCount}: ${formatBatesText(template, lastBates, lastEntry.pageCount, fileStart + files.length - 1, lastName, padding)}`
);
}
previewEl.textContent = lines.join('\n');
}
function calculatePosition(
pageWidth: number,
pageHeight: number,
xOffset: number,
yOffset: number,
textWidth: number,
fontSize: number,
position: Position
): { x: number; y: number } {
const minMargin = 8;
const maxMargin = 40;
const marginPct = 0.04;
const hMargin = Math.max(
minMargin,
Math.min(maxMargin, pageWidth * marginPct)
);
const vMargin = Math.max(
minMargin,
Math.min(maxMargin, pageHeight * marginPct)
);
const safeH = Math.max(hMargin, textWidth / 2 + 3);
const safeV = Math.max(vMargin, fontSize + 3);
let x = 0,
y = 0;
switch (position) {
case 'bottom-center':
x =
Math.max(
safeH,
Math.min(pageWidth - safeH - textWidth, (pageWidth - textWidth) / 2)
) + xOffset;
y = safeV + yOffset;
break;
case 'bottom-left':
x = safeH + xOffset;
y = safeV + yOffset;
break;
case 'bottom-right':
x = Math.max(safeH, pageWidth - safeH - textWidth) + xOffset;
y = safeV + yOffset;
break;
case 'top-center':
x =
Math.max(
safeH,
Math.min(pageWidth - safeH - textWidth, (pageWidth - textWidth) / 2)
) + xOffset;
y = pageHeight - safeV - fontSize + yOffset;
break;
case 'top-left':
x = safeH + xOffset;
y = pageHeight - safeV - fontSize + yOffset;
break;
case 'top-right':
x = Math.max(safeH, pageWidth - safeH - textWidth) + xOffset;
y = pageHeight - safeV - fontSize + yOffset;
break;
}
x = Math.max(xOffset + 3, Math.min(xOffset + pageWidth - textWidth - 3, x));
y = Math.max(yOffset + 3, Math.min(yOffset + pageHeight - fontSize - 3, y));
return { x, y };
}
function resetState() {
files.length = 0;
const fileListEl = document.getElementById('file-list');
if (fileListEl) fileListEl.innerHTML = '';
document.getElementById('options-panel')?.classList.add('hidden');
document.getElementById('file-controls')?.classList.add('hidden');
const fileInput = document.getElementById('file-input') as HTMLInputElement;
if (fileInput) fileInput.value = '';
}
async function applyBatesNumbers() {
if (files.length === 0) {
showAlert('Error', 'Please upload at least one PDF file.');
return;
}
showLoader('Applying Bates numbers...');
try {
const template = (
document.getElementById('bates-template') as HTMLInputElement
).value;
const padding = getActivePadding();
const batesStart =
parseInt(
(document.getElementById('bates-start') as HTMLInputElement).value
) || 1;
const fileStart =
parseInt(
(document.getElementById('file-start') as HTMLInputElement).value
) || 1;
const position = (document.getElementById('position') as HTMLSelectElement)
.value as Position;
const fontKey = (
document.getElementById('font-family') as HTMLSelectElement
).value;
const fontSize =
parseInt(
(document.getElementById('font-size') as HTMLInputElement).value
) || 10;
const colorHex = (document.getElementById('text-color') as HTMLInputElement)
.value;
const textColor = hexToRgb(colorHex);
const fontName = FONT_MAP[fontKey] || 'Helvetica';
const results: { name: string; bytes: Uint8Array }[] = [];
let batesCounter = batesStart;
let fileCounter = fileStart;
for (const entry of files) {
const arrayBuffer = await entry.file.arrayBuffer();
const pdfDoc = await PDFDocument.load(arrayBuffer);
const font = await pdfDoc.embedFont(StandardFonts[fontName]);
const pages = pdfDoc.getPages();
const fileName = entry.file.name.replace(/\.pdf$/i, '');
for (let i = 0; i < pages.length; i++) {
const page = pages[i];
const bounds = page.getCropBox() || page.getMediaBox();
const text = formatBatesText(
template,
batesCounter,
i + 1,
fileCounter,
fileName,
padding
);
const textWidth = font.widthOfTextAtSize(text, fontSize);
const { x, y } = calculatePosition(
bounds.width,
bounds.height,
bounds.x || 0,
bounds.y || 0,
textWidth,
fontSize,
position
);
page.drawText(text, {
x,
y,
font,
size: fontSize,
color: rgb(textColor.r, textColor.g, textColor.b),
});
batesCounter++;
}
fileCounter++;
const pdfBytes = await pdfDoc.save();
results.push({
name: `bates_${entry.file.name}`,
bytes: new Uint8Array(pdfBytes),
});
}
if (results.length === 1) {
downloadFile(
new Blob([new Uint8Array(results[0].bytes)], {
type: 'application/pdf',
}),
results[0].name
);
} else {
const zip = new JSZip();
for (const result of results) {
zip.file(result.name, result.bytes);
}
const zipBlob = await zip.generateAsync({ type: 'blob' });
downloadFile(zipBlob, 'bates_numbered.zip');
}
showAlert(
'Success',
`Bates numbers applied successfully! (${batesStart} through ${batesCounter - 1})`,
'success',
() => {
resetState();
}
);
} catch (e) {
console.error(e);
showAlert('Error', 'Failed to apply Bates numbers.');
} finally {
hideLoader();
}
}
+1 -1
View File
@@ -253,7 +253,7 @@ placeholder="${field.placeholder || ''}" />
) )
.join('')} .join('')}
</select> </select>
${field.name === 'color' ? '<input type="color" id="modal-color-picker" class="hidden w-full h-10 mt-2 rounded cursor-pointer border border-gray-300" value="#000000" />' : ''} ${field.name === 'color' ? '<input type="color" id="modal-color-picker" class="hidden mt-2" value="#000000" />' : ''}
</div> </div>
`; `;
} else if (field.type === 'destination') { } else if (field.type === 'destination') {
+217 -178
View File
@@ -2,82 +2,94 @@ import { PdfSigner, type SignOption } from 'zgapdfsigner';
import forge from 'node-forge'; import forge from 'node-forge';
import { CertificateData, SignPdfOptions } from '@/types'; import { CertificateData, SignPdfOptions } from '@/types';
export function parsePfxFile(pfxBytes: ArrayBuffer, password: string): CertificateData { export function parsePfxFile(
const pfxAsn1 = forge.asn1.fromDer(forge.util.createBuffer(new Uint8Array(pfxBytes))); pfxBytes: ArrayBuffer,
const pfx = forge.pkcs12.pkcs12FromAsn1(pfxAsn1, password); password: string
): CertificateData {
const pfxAsn1 = forge.asn1.fromDer(
forge.util.createBuffer(new Uint8Array(pfxBytes))
);
const pfx = forge.pkcs12.pkcs12FromAsn1(pfxAsn1, password);
const certBags = pfx.getBags({ bagType: forge.pki.oids.certBag }); const certBags = pfx.getBags({ bagType: forge.pki.oids.certBag });
const keyBags = pfx.getBags({ bagType: forge.pki.oids.pkcs8ShroudedKeyBag }); const keyBags = pfx.getBags({ bagType: forge.pki.oids.pkcs8ShroudedKeyBag });
const certBagArray = certBags[forge.pki.oids.certBag]; const certBagArray = certBags[forge.pki.oids.certBag];
const keyBagArray = keyBags[forge.pki.oids.pkcs8ShroudedKeyBag]; const keyBagArray = keyBags[forge.pki.oids.pkcs8ShroudedKeyBag];
if (!certBagArray || certBagArray.length === 0) { if (!certBagArray || certBagArray.length === 0) {
throw new Error('No certificate found in PFX file'); throw new Error('No certificate found in PFX file');
} }
if (!keyBagArray || keyBagArray.length === 0) { if (!keyBagArray || keyBagArray.length === 0) {
throw new Error('No private key found in PFX file'); throw new Error('No private key found in PFX file');
} }
const certificate = certBagArray[0].cert; const certificate = certBagArray[0].cert;
if (!certificate) { if (!certificate) {
throw new Error('Failed to extract certificate from PFX file'); throw new Error('Failed to extract certificate from PFX file');
} }
return { p12Buffer: pfxBytes, password, certificate }; return { p12Buffer: pfxBytes, password, certificate };
} }
export function parsePemFiles( export function parsePemFiles(
certPem: string, certPem: string,
keyPem: string, keyPem: string,
keyPassword?: string keyPassword?: string
): CertificateData { ): CertificateData {
const certificate = forge.pki.certificateFromPem(certPem); const certificate = forge.pki.certificateFromPem(certPem);
let privateKey: forge.pki.PrivateKey; let privateKey: forge.pki.PrivateKey;
if (keyPem.includes('ENCRYPTED')) { if (keyPem.includes('ENCRYPTED')) {
if (!keyPassword) { if (!keyPassword) {
throw new Error('Password required for encrypted private key'); throw new Error('Password required for encrypted private key');
}
privateKey = forge.pki.decryptRsaPrivateKey(keyPem, keyPassword);
if (!privateKey) {
throw new Error('Failed to decrypt private key');
}
} else {
privateKey = forge.pki.privateKeyFromPem(keyPem);
} }
privateKey = forge.pki.decryptRsaPrivateKey(keyPem, keyPassword);
const p12Password = keyPassword || 'temp-password'; if (!privateKey) {
const p12Asn1 = forge.pkcs12.toPkcs12Asn1( throw new Error('Failed to decrypt private key');
privateKey,
[certificate],
p12Password,
{ algorithm: '3des' }
);
const p12Der = forge.asn1.toDer(p12Asn1).getBytes();
const p12Buffer = new Uint8Array(p12Der.length);
for (let i = 0; i < p12Der.length; i++) {
p12Buffer[i] = p12Der.charCodeAt(i);
} }
} else {
privateKey = forge.pki.privateKeyFromPem(keyPem);
}
return { p12Buffer: p12Buffer.buffer, password: p12Password, certificate }; const p12Password = keyPassword || crypto.randomUUID();
const p12Asn1 = forge.pkcs12.toPkcs12Asn1(
privateKey,
[certificate],
p12Password,
{ algorithm: '3des' }
);
const p12Der = forge.asn1.toDer(p12Asn1).getBytes();
const p12Buffer = new Uint8Array(p12Der.length);
for (let i = 0; i < p12Der.length; i++) {
p12Buffer[i] = p12Der.charCodeAt(i);
}
return { p12Buffer: p12Buffer.buffer, password: p12Password, certificate };
} }
export function parseCombinedPem(pemContent: string, password?: string): CertificateData { export function parseCombinedPem(
const certMatch = pemContent.match(/-----BEGIN CERTIFICATE-----[\s\S]*?-----END CERTIFICATE-----/); pemContent: string,
const keyMatch = pemContent.match(/-----BEGIN (RSA |EC |ENCRYPTED )?PRIVATE KEY-----[\s\S]*?-----END (RSA |EC |ENCRYPTED )?PRIVATE KEY-----/); password?: string
): CertificateData {
const certMatch = pemContent.match(
/-----BEGIN CERTIFICATE-----[\s\S]*?-----END CERTIFICATE-----/
);
const keyMatch = pemContent.match(
/-----BEGIN (RSA |EC |ENCRYPTED )?PRIVATE KEY-----[\s\S]*?-----END (RSA |EC |ENCRYPTED )?PRIVATE KEY-----/
);
if (!certMatch) { if (!certMatch) {
throw new Error('No certificate found in PEM file'); throw new Error('No certificate found in PEM file');
} }
if (!keyMatch) { if (!keyMatch) {
throw new Error('No private key found in PEM file'); throw new Error('No private key found in PEM file');
} }
return parsePemFiles(certMatch[0], keyMatch[0], password); return parsePemFiles(certMatch[0], keyMatch[0], password);
} }
/** /**
@@ -116,26 +128,29 @@ const CORS_PROXY_URL = import.meta.env.VITE_CORS_PROXY_URL || '';
*/ */
const CORS_PROXY_SECRET = import.meta.env.VITE_CORS_PROXY_SECRET || ''; const CORS_PROXY_SECRET = import.meta.env.VITE_CORS_PROXY_SECRET || '';
async function generateProxySignature(url: string, timestamp: number): Promise<string> { async function generateProxySignature(
const encoder = new TextEncoder(); url: string,
const key = await crypto.subtle.importKey( timestamp: number
'raw', ): Promise<string> {
encoder.encode(CORS_PROXY_SECRET), const encoder = new TextEncoder();
{ name: 'HMAC', hash: 'SHA-256' }, const key = await crypto.subtle.importKey(
false, 'raw',
['sign'] encoder.encode(CORS_PROXY_SECRET),
); { name: 'HMAC', hash: 'SHA-256' },
false,
['sign']
);
const message = `${url}${timestamp}`; const message = `${url}${timestamp}`;
const signature = await crypto.subtle.sign( const signature = await crypto.subtle.sign(
'HMAC', 'HMAC',
key, key,
encoder.encode(message) encoder.encode(message)
); );
return Array.from(new Uint8Array(signature)) return Array.from(new Uint8Array(signature))
.map(b => b.toString(16).padStart(2, '0')) .map((b) => b.toString(16).padStart(2, '0'))
.join(''); .join('');
} }
/** /**
@@ -145,139 +160,163 @@ async function generateProxySignature(url: string, timestamp: number): Promise<s
* the fetch fails. This wrapper routes such requests through our CORS proxy. * the fetch fails. This wrapper routes such requests through our CORS proxy.
* *
* If VITE_CORS_PROXY_SECRET is configured, requests include HMAC signatures for anti-spoofing. * If VITE_CORS_PROXY_SECRET is configured, requests include HMAC signatures for anti-spoofing.
*
*/ */
let fetchWrapRefCount = 0;
let savedOriginalFetch: typeof fetch | null = null;
function createCorsAwareFetch(): { function createCorsAwareFetch(): {
wrappedFetch: typeof fetch; wrappedFetch: typeof fetch;
restore: () => void; restore: () => void;
} { } {
const originalFetch = window.fetch.bind(window); if (fetchWrapRefCount === 0) {
savedOriginalFetch = window.fetch.bind(window);
const wrappedFetch: typeof fetch = async (input, init) => { const originalFetch = savedOriginalFetch;
const url = typeof input === 'string' ? input : input instanceof URL ? input.toString() : input.url;
const isExternalCertificateUrl = ( window.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => {
url.includes('.crt') || const url =
url.includes('.cer') || typeof input === 'string'
url.includes('.pem') || ? input
url.includes('/certs/') || : input instanceof URL
url.includes('/ocsp') || ? input.toString()
url.includes('/crl') || : input.url;
url.includes('caIssuers')
) && !url.startsWith(window.location.origin);
if (isExternalCertificateUrl && CORS_PROXY_URL) { const isExternalCertificateUrl =
let proxyUrl = `${CORS_PROXY_URL}?url=${encodeURIComponent(url)}`; (url.includes('.crt') ||
url.includes('.cer') ||
url.includes('.pem') ||
url.includes('/certs/') ||
url.includes('/ocsp') ||
url.includes('/crl') ||
url.includes('caIssuers')) &&
!url.startsWith(window.location.origin);
if (CORS_PROXY_SECRET) { if (isExternalCertificateUrl && CORS_PROXY_URL) {
const timestamp = Date.now(); let proxyUrl = `${CORS_PROXY_URL}?url=${encodeURIComponent(url)}`;
const signature = await generateProxySignature(url, timestamp);
proxyUrl += `&t=${timestamp}&sig=${signature}`;
console.log(`[CORS Proxy] Routing signed certificate request through proxy: ${url}`);
} else {
console.log(`[CORS Proxy] Routing certificate request through proxy: ${url}`);
}
return originalFetch(proxyUrl, init); if (CORS_PROXY_SECRET) {
const timestamp = Date.now();
const signature = await generateProxySignature(url, timestamp);
proxyUrl += `&t=${timestamp}&sig=${signature}`;
console.log(
`[CORS Proxy] Routing signed certificate request through proxy: ${url}`
);
} else {
console.log(
`[CORS Proxy] Routing certificate request through proxy: ${url}`
);
} }
return originalFetch(input, init); return originalFetch(proxyUrl, init);
}; }
window.fetch = wrappedFetch; return originalFetch(input, init);
}) as typeof fetch;
}
return { fetchWrapRefCount++;
wrappedFetch,
restore: () => { return {
window.fetch = originalFetch; wrappedFetch: window.fetch,
} restore: () => {
}; fetchWrapRefCount--;
if (fetchWrapRefCount === 0 && savedOriginalFetch) {
window.fetch = savedOriginalFetch;
savedOriginalFetch = null;
}
},
};
} }
export async function signPdf( export async function signPdf(
pdfBytes: Uint8Array, pdfBytes: Uint8Array,
certificateData: CertificateData, certificateData: CertificateData,
options: SignPdfOptions = {} options: SignPdfOptions = {}
): Promise<Uint8Array> { ): Promise<Uint8Array> {
const signatureInfo = options.signatureInfo ?? {}; const signatureInfo = options.signatureInfo ?? {};
const signOptions: SignOption = { const signOptions: SignOption = {
p12cert: certificateData.p12Buffer, p12cert: certificateData.p12Buffer,
pwd: certificateData.password, pwd: certificateData.password,
};
if (signatureInfo.reason) {
signOptions.reason = signatureInfo.reason;
}
if (signatureInfo.location) {
signOptions.location = signatureInfo.location;
}
if (signatureInfo.contactInfo) {
signOptions.contact = signatureInfo.contactInfo;
}
if (options.visibleSignature?.enabled) {
const vs = options.visibleSignature;
const drawinf = {
area: {
x: vs.x,
y: vs.y,
w: vs.width,
h: vs.height,
},
pageidx: vs.page,
imgInfo: undefined as
| { imgData: ArrayBuffer; imgType: string }
| undefined,
textInfo: undefined as
| { text: string; size: number; color: string }
| undefined,
}; };
if (signatureInfo.reason) { if (vs.imageData && vs.imageType) {
signOptions.reason = signatureInfo.reason; drawinf.imgInfo = {
imgData: vs.imageData,
imgType: vs.imageType,
};
} }
if (signatureInfo.location) { if (vs.text) {
signOptions.location = signatureInfo.location; drawinf.textInfo = {
text: vs.text,
size: vs.textSize ?? 12,
color: vs.textColor ?? '#000000',
};
} }
if (signatureInfo.contactInfo) { signOptions.drawinf = drawinf as SignOption['drawinf'];
signOptions.contact = signatureInfo.contactInfo; }
}
if (options.visibleSignature?.enabled) { const signer = new PdfSigner(signOptions);
const vs = options.visibleSignature;
const drawinf = { const { restore } = createCorsAwareFetch();
area: {
x: vs.x,
y: vs.y,
w: vs.width,
h: vs.height,
},
pageidx: vs.page,
imgInfo: undefined as { imgData: ArrayBuffer; imgType: string } | undefined,
textInfo: undefined as { text: string; size: number; color: string } | undefined,
};
if (vs.imageData && vs.imageType) { try {
drawinf.imgInfo = { const signedPdfBytes = await signer.sign(pdfBytes);
imgData: vs.imageData, return new Uint8Array(signedPdfBytes);
imgType: vs.imageType, } finally {
}; restore();
} }
if (vs.text) {
drawinf.textInfo = {
text: vs.text,
size: vs.textSize ?? 12,
color: vs.textColor ?? '#000000',
};
}
signOptions.drawinf = drawinf as SignOption['drawinf'];
}
const signer = new PdfSigner(signOptions);
const { restore } = createCorsAwareFetch();
try {
const signedPdfBytes = await signer.sign(pdfBytes);
return new Uint8Array(signedPdfBytes);
} finally {
restore();
}
} }
export function getCertificateInfo(certificate: forge.pki.Certificate): { export function getCertificateInfo(certificate: forge.pki.Certificate): {
subject: string; subject: string;
issuer: string; issuer: string;
validFrom: Date; validFrom: Date;
validTo: Date; validTo: Date;
serialNumber: string; serialNumber: string;
} { } {
const subjectCN = certificate.subject.getField('CN'); const subjectCN = certificate.subject.getField('CN');
const issuerCN = certificate.issuer.getField('CN'); const issuerCN = certificate.issuer.getField('CN');
return { return {
subject: subjectCN?.value as string ?? 'Unknown', subject: (subjectCN?.value as string) ?? 'Unknown',
issuer: issuerCN?.value as string ?? 'Unknown', issuer: (issuerCN?.value as string) ?? 'Unknown',
validFrom: certificate.validity.notBefore, validFrom: certificate.validity.notBefore,
validTo: certificate.validity.notAfter, validTo: certificate.validity.notAfter,
serialNumber: certificate.serialNumber, serialNumber: certificate.serialNumber,
}; };
} }
+2 -2
View File
@@ -931,7 +931,7 @@ function showProperties(field: FormField): void {
</div> </div>
<div> <div>
<label class="block text-xs font-semibold text-gray-300 mb-1">Text Color</label> <label class="block text-xs font-semibold text-gray-300 mb-1">Text Color</label>
<input type="color" id="propTextColor" value="${field.textColor}" class="w-full border border-gray-500 rounded px-2 py-1 h-10"> <input type="color" id="propTextColor" value="${field.textColor}">
</div> </div>
<div> <div>
<label class="block text-xs font-semibold text-gray-300 mb-1">Alignment</label> <label class="block text-xs font-semibold text-gray-300 mb-1">Alignment</label>
@@ -1169,7 +1169,7 @@ function showProperties(field: FormField): void {
</div> </div>
<div> <div>
<label class="block text-xs font-semibold text-gray-300 mb-1">Border Color</label> <label class="block text-xs font-semibold text-gray-300 mb-1">Border Color</label>
<input type="color" id="propBorderColor" value="${field.borderColor || '#000000'}" class="w-full border border-gray-500 rounded px-2 py-1 h-10"> <input type="color" id="propBorderColor" value="${field.borderColor || '#000000'}">
</div> </div>
<div class="flex items-center"> <div class="flex items-center">
<input type="checkbox" id="propHideBorder" ${field.hideBorder ? 'checked' : ''} class="mr-2"> <input type="checkbox" id="propHideBorder" ${field.hideBorder ? 'checked' : ''} class="mr-2">
+396 -130
View File
@@ -1,10 +1,15 @@
import { createIcons, icons } from 'lucide'; import { createIcons, icons } from 'lucide';
import { degrees, PDFDocument as PDFLibDocument } from 'pdf-lib'; import { degrees, PDFDocument as PDFLibDocument, PDFPage } from 'pdf-lib';
import * as pdfjsLib from 'pdfjs-dist'; import * as pdfjsLib from 'pdfjs-dist';
import JSZip from 'jszip'; import JSZip from 'jszip';
import Sortable from 'sortablejs'; import Sortable from 'sortablejs';
import { downloadFile, getPDFDocument } from '../utils/helpers'; import { downloadFile, getPDFDocument } from '../utils/helpers';
import { renderPagesProgressively, cleanupLazyRendering, renderPageToCanvas, createPlaceholder } from '../utils/render-utils'; import {
renderPagesProgressively,
cleanupLazyRendering,
renderPageToCanvas,
createPlaceholder,
} from '../utils/render-utils';
import { initializeGlobalShortcuts } from '../utils/shortcuts-init.js'; import { initializeGlobalShortcuts } from '../utils/shortcuts-init.js';
import { repairPdfFile } from './repair-pdf.js'; import { repairPdfFile } from './repair-pdf.js';
@@ -41,13 +46,17 @@ let sortableInstance: Sortable | null = null;
const pageCanvasCache = new Map<string, HTMLCanvasElement>(); const pageCanvasCache = new Map<string, HTMLCanvasElement>();
type Snapshot = { allPages: PageData[]; selectedPages: number[]; splitMarkers: number[] }; type Snapshot = {
allPages: PageData[];
selectedPages: number[];
splitMarkers: number[];
};
const undoStack: Snapshot[] = []; const undoStack: Snapshot[] = [];
const redoStack: Snapshot[] = []; const redoStack: Snapshot[] = [];
function snapshot() { function snapshot() {
const snap: Snapshot = { const snap: Snapshot = {
allPages: allPages.map(p => ({ ...p, canvas: p.canvas })), allPages: allPages.map((p) => ({ ...p, canvas: p.canvas })),
selectedPages: Array.from(selectedPages), selectedPages: Array.from(selectedPages),
splitMarkers: Array.from(splitMarkers), splitMarkers: Array.from(splitMarkers),
}; };
@@ -56,16 +65,20 @@ function snapshot() {
} }
function restore(snap: Snapshot) { function restore(snap: Snapshot) {
allPages = snap.allPages.map(p => ({ allPages = snap.allPages.map((p) => ({
...p, ...p,
canvas: p.canvas canvas: p.canvas,
})); }));
selectedPages = new Set(snap.selectedPages); selectedPages = new Set(snap.selectedPages);
splitMarkers = new Set(snap.splitMarkers); splitMarkers = new Set(snap.splitMarkers);
updatePageDisplay(); updatePageDisplay();
} }
function showModal(title: string, message: string, type: 'info' | 'error' | 'success' = 'info') { function showModal(
title: string,
message: string,
type: 'info' | 'error' | 'success' = 'info'
) {
const modal = document.getElementById('modal'); const modal = document.getElementById('modal');
const modalTitle = document.getElementById('modal-title'); const modalTitle = document.getElementById('modal-title');
const modalMessage = document.getElementById('modal-message'); const modalMessage = document.getElementById('modal-message');
@@ -79,12 +92,12 @@ function showModal(title: string, message: string, type: 'info' | 'error' | 'suc
const iconMap = { const iconMap = {
info: 'info', info: 'info',
error: 'alert-circle', error: 'alert-circle',
success: 'check-circle' success: 'check-circle',
}; };
const colorMap = { const colorMap = {
info: 'text-blue-400', info: 'text-blue-400',
error: 'text-red-400', error: 'text-red-400',
success: 'text-green-400' success: 'text-green-400',
}; };
modalIcon.innerHTML = `<i data-lucide="${iconMap[type]}" class="w-12 h-12 ${colorMap[type]}"></i>`; modalIcon.innerHTML = `<i data-lucide="${iconMap[type]}" class="w-12 h-12 ${colorMap[type]}"></i>`;
@@ -112,7 +125,10 @@ function showLoading(current: number, total: number) {
text.textContent = t('multiTool.renderingPages'); text.textContent = t('multiTool.renderingPages');
} }
async function withButtonLoading(buttonId: string, action: () => Promise<void>) { async function withButtonLoading(
buttonId: string,
action: () => Promise<void>
) {
const button = document.getElementById(buttonId) as HTMLButtonElement; const button = document.getElementById(buttonId) as HTMLButtonElement;
if (!button) return; if (!button) return;
@@ -122,7 +138,8 @@ async function withButtonLoading(buttonId: string, action: () => Promise<void>)
try { try {
button.disabled = true; button.disabled = true;
button.style.pointerEvents = 'none'; button.style.pointerEvents = 'none';
button.innerHTML = '<svg class="animate-spin h-5 w-5 text-white" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle><path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path></svg>'; button.innerHTML =
'<svg class="animate-spin h-5 w-5 text-white" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle><path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path></svg>';
await action(); await action();
} finally { } finally {
@@ -143,7 +160,9 @@ if (document.readyState === 'loading') {
initializeTool(); initializeTool();
}); });
} else { } else {
console.log('PDF Multi Tool: DOMContentLoaded already fired, initializing immediately'); console.log(
'PDF Multi Tool: DOMContentLoaded already fired, initializing immediately'
);
initializeTool(); initializeTool();
} }
@@ -160,20 +179,30 @@ function initializeTool() {
document.getElementById('upload-pdfs-btn')?.addEventListener('click', () => { document.getElementById('upload-pdfs-btn')?.addEventListener('click', () => {
console.log('Upload button clicked, isRendering:', isRendering); console.log('Upload button clicked, isRendering:', isRendering);
if (isRendering) { if (isRendering) {
showModal(t('multiTool.pleaseWait'), t('multiTool.pagesRendering'), 'info'); showModal(
t('multiTool.pleaseWait'),
t('multiTool.pagesRendering'),
'info'
);
return; return;
} }
document.getElementById('pdf-file-input')?.click(); document.getElementById('pdf-file-input')?.click();
}); });
document.getElementById('pdf-file-input')?.addEventListener('change', handlePdfUpload); document
document.getElementById('insert-pdf-input')?.addEventListener('change', handleInsertPdf); .getElementById('pdf-file-input')
?.addEventListener('change', handlePdfUpload);
document
.getElementById('insert-pdf-input')
?.addEventListener('change', handleInsertPdf);
document.getElementById('bulk-rotate-left-btn')?.addEventListener('click', () => { document
if (isRendering) return; .getElementById('bulk-rotate-left-btn')
snapshot(); ?.addEventListener('click', () => {
bulkRotate(-90); if (isRendering) return;
}); snapshot();
bulkRotate(-90);
});
document.getElementById('bulk-rotate-btn')?.addEventListener('click', () => { document.getElementById('bulk-rotate-btn')?.addEventListener('click', () => {
if (isRendering) return; if (isRendering) return;
snapshot(); snapshot();
@@ -184,27 +213,38 @@ function initializeTool() {
snapshot(); snapshot();
bulkDelete(); bulkDelete();
}); });
document.getElementById('bulk-duplicate-btn')?.addEventListener('click', () => { document
if (isRendering) return; .getElementById('bulk-duplicate-btn')
snapshot(); ?.addEventListener('click', () => {
bulkDuplicate(); if (isRendering) return;
}); snapshot();
bulkDuplicate();
});
document.getElementById('bulk-split-btn')?.addEventListener('click', () => { document.getElementById('bulk-split-btn')?.addEventListener('click', () => {
if (isRendering) return; if (isRendering) return;
snapshot(); snapshot();
bulkSplit(); bulkSplit();
}); });
document.getElementById('bulk-download-btn')?.addEventListener('click', () => { document
if (isRendering) return; .getElementById('bulk-download-btn')
if (isRendering) return; ?.addEventListener('click', () => {
if (selectedPages.size === 0) { if (isRendering) return;
showModal(t('multiTool.noPagesSelected'), t('multiTool.selectOnePage'), 'info'); if (isRendering) return;
return; if (selectedPages.size === 0) {
} showModal(
withButtonLoading('bulk-download-btn', async () => { t('multiTool.noPagesSelected'),
await downloadPagesAsPdf(Array.from(selectedPages).sort((a, b) => a - b), 'selected-pages.pdf'); t('multiTool.selectOnePage'),
'info'
);
return;
}
withButtonLoading('bulk-download-btn', async () => {
await downloadPagesAsPdf(
Array.from(selectedPages).sort((a, b) => a - b),
'selected-pages.pdf'
);
});
}); });
});
document.getElementById('select-all-btn')?.addEventListener('click', () => { document.getElementById('select-all-btn')?.addEventListener('click', () => {
if (isRendering) return; if (isRendering) return;
@@ -229,17 +269,19 @@ function initializeTool() {
await downloadAll(); await downloadAll();
}); });
}); });
document.getElementById('add-blank-page-btn')?.addEventListener('click', () => { document
if (isRendering) return; .getElementById('add-blank-page-btn')
snapshot(); ?.addEventListener('click', () => {
addBlankPage(); if (isRendering) return;
}); snapshot();
addBlankPage();
});
document.getElementById('undo-btn')?.addEventListener('click', () => { document.getElementById('undo-btn')?.addEventListener('click', () => {
if (isRendering) return; if (isRendering) return;
const last = undoStack.pop(); const last = undoStack.pop();
if (last) { if (last) {
const current: Snapshot = { const current: Snapshot = {
allPages: allPages.map(p => ({ ...p })), allPages: allPages.map((p) => ({ ...p })),
selectedPages: Array.from(selectedPages), selectedPages: Array.from(selectedPages),
splitMarkers: Array.from(splitMarkers), splitMarkers: Array.from(splitMarkers),
}; };
@@ -252,7 +294,7 @@ function initializeTool() {
const next = redoStack.pop(); const next = redoStack.pop();
if (next) { if (next) {
const current: Snapshot = { const current: Snapshot = {
allPages: allPages.map(p => ({ ...p })), allPages: allPages.map((p) => ({ ...p })),
selectedPages: Array.from(selectedPages), selectedPages: Array.from(selectedPages),
splitMarkers: Array.from(splitMarkers), splitMarkers: Array.from(splitMarkers),
}; };
@@ -269,7 +311,9 @@ function initializeTool() {
} }
}); });
document.getElementById('modal-close-btn')?.addEventListener('click', hideModal); document
.getElementById('modal-close-btn')
?.addEventListener('click', hideModal);
document.getElementById('modal')?.addEventListener('click', (e) => { document.getElementById('modal')?.addEventListener('click', (e) => {
if (e.target === document.getElementById('modal')) { if (e.target === document.getElementById('modal')) {
hideModal(); hideModal();
@@ -288,7 +332,9 @@ function initializeTool() {
uploadArea.addEventListener('drop', (e) => { uploadArea.addEventListener('drop', (e) => {
e.preventDefault(); e.preventDefault();
uploadArea.classList.remove('border-indigo-500'); uploadArea.classList.remove('border-indigo-500');
const files = Array.from(e.dataTransfer?.files || []).filter(f => f.type === 'application/pdf'); const files = Array.from(e.dataTransfer?.files || []).filter(
(f) => f.type === 'application/pdf'
);
if (files.length > 0) { if (files.length > 0) {
loadPdfs(files); loadPdfs(files);
} }
@@ -361,27 +407,38 @@ async function loadPdfs(files: File[]) {
try { try {
console.log(`Repairing ${file.name}...`); console.log(`Repairing ${file.name}...`);
const loadingText = document.getElementById('loading-text'); const loadingText = document.getElementById('loading-text');
if (loadingText) loadingText.textContent = `Repairing ${file.name}...`; if (loadingText)
loadingText.textContent = `Repairing ${file.name}...`;
const repairedData = await repairPdfFile(file); const repairedData = await repairPdfFile(file);
if (repairedData) { if (repairedData) {
arrayBuffer = repairedData.buffer as ArrayBuffer; arrayBuffer = repairedData.buffer as ArrayBuffer;
console.log(`Successfully repaired ${file.name} before loading.`); console.log(`Successfully repaired ${file.name} before loading.`);
} else { } else {
console.warn(`Repair returned null for ${file.name}, using original file.`); console.warn(
`Repair returned null for ${file.name}, using original file.`
);
arrayBuffer = await file.arrayBuffer(); arrayBuffer = await file.arrayBuffer();
} }
} catch (repairError) { } catch (repairError) {
console.warn(`Failed to repair ${file.name}, attempting to load original:`, repairError); console.warn(
`Failed to repair ${file.name}, attempting to load original:`,
repairError
);
arrayBuffer = await file.arrayBuffer(); arrayBuffer = await file.arrayBuffer();
} }
const pdfDoc = await PDFLibDocument.load(arrayBuffer, { ignoreEncryption: true, throwOnInvalidObject: false }); const pdfDoc = await PDFLibDocument.load(arrayBuffer, {
ignoreEncryption: true,
throwOnInvalidObject: false,
});
currentPdfDocs.push(pdfDoc); currentPdfDocs.push(pdfDoc);
const pdfIndex = currentPdfDocs.length - 1; const pdfIndex = currentPdfDocs.length - 1;
const pdfBytes = await pdfDoc.save(); const pdfBytes = await pdfDoc.save();
const pdfjsDoc = await getPDFDocument({ data: new Uint8Array(pdfBytes) }).promise; const pdfjsDoc = await getPDFDocument({
data: new Uint8Array(pdfBytes),
}).promise;
const numPages = pdfjsDoc.numPages; const numPages = pdfjsDoc.numPages;
// Pre-fill allPages with placeholders to maintain order/state // Pre-fill allPages with placeholders to maintain order/state
@@ -425,10 +482,13 @@ async function loadPdfs(files: File[]) {
shouldCancel: () => renderCancelled, // Pass cancellation check shouldCancel: () => renderCancelled, // Pass cancellation check
} }
); );
} catch (e) { } catch (e) {
console.error(`Failed to load PDF ${file.name}:`, e); console.error(`Failed to load PDF ${file.name}:`, e);
showModal(t('multiTool.error'), `${t('multiTool.failedToLoad')} ${file.name}.`, 'error'); showModal(
t('multiTool.error'),
`${t('multiTool.failedToLoad')} ${file.name}.`,
'error'
);
} }
} }
@@ -439,7 +499,9 @@ async function loadPdfs(files: File[]) {
} finally { } finally {
hideLoading(); hideLoading();
isRendering = false; isRendering = false;
console.log('PDF Multi Tool: Render finished/cancelled, isRendering set to false'); console.log(
'PDF Multi Tool: Render finished/cancelled, isRendering set to false'
);
if (renderCancelled) { if (renderCancelled) {
renderCancelled = false; renderCancelled = false;
} }
@@ -464,7 +526,10 @@ function createPageCard(pageData: PageData, index: number) {
} }
// Modified to return the element instead of appending it // Modified to return the element instead of appending it
function createPageElement(canvas: HTMLCanvasElement | null, index: number): HTMLElement { function createPageElement(
canvas: HTMLCanvasElement | null,
index: number
): HTMLElement {
const pageData = allPages[index]; const pageData = allPages[index];
if (!pageData) { if (!pageData) {
console.error(`Page data not found for index ${index}`); console.error(`Page data not found for index ${index}`);
@@ -472,7 +537,8 @@ function createPageElement(canvas: HTMLCanvasElement | null, index: number): HTM
} }
const card = document.createElement('div'); const card = document.createElement('div');
card.className = 'bg-gray-800 rounded-lg border-2 border-gray-700 p-2 relative group cursor-move'; card.className =
'bg-gray-800 rounded-lg border-2 border-gray-700 p-2 relative group cursor-move';
card.dataset.pageIndex = index.toString(); card.dataset.pageIndex = index.toString();
card.dataset.pageId = pageData.id; // Set ID for reconciliation card.dataset.pageId = pageData.id; // Set ID for reconciliation
@@ -490,7 +556,8 @@ function createPageElement(canvas: HTMLCanvasElement | null, index: number): HTM
} }
const preview = document.createElement('div'); const preview = document.createElement('div');
preview.className = 'bg-white rounded mb-2 overflow-hidden w-full flex items-center justify-center relative h-36 sm:h-64'; preview.className =
'bg-white rounded mb-2 overflow-hidden w-full flex items-center justify-center relative h-36 sm:h-64';
if (canvas) { if (canvas) {
const previewCanvas = canvas; const previewCanvas = canvas;
@@ -502,7 +569,8 @@ function createPageElement(canvas: HTMLCanvasElement | null, index: number): HTM
} else { } else {
// Show loading placeholder if canvas is null // Show loading placeholder if canvas is null
const loading = document.createElement('div'); const loading = document.createElement('div');
loading.className = 'flex flex-col items-center justify-center text-gray-400'; loading.className =
'flex flex-col items-center justify-center text-gray-400';
loading.innerHTML = ` loading.innerHTML = `
<i data-lucide="loader" class="w-8 h-8 animate-spin mb-2"></i> <i data-lucide="loader" class="w-8 h-8 animate-spin mb-2"></i>
<span class="text-xs">${t('common.loading')}</span> <span class="text-xs">${t('common.loading')}</span>
@@ -518,15 +586,18 @@ function createPageElement(canvas: HTMLCanvasElement | null, index: number): HTM
// Actions toolbar // Actions toolbar
const actions = document.createElement('div'); const actions = document.createElement('div');
actions.className = 'flex items-center justify-center gap-1 sm:opacity-0 group-hover:opacity-100 transition-opacity absolute bottom-2 left-0 right-0'; actions.className =
'flex items-center justify-center gap-1 sm:opacity-0 group-hover:opacity-100 transition-opacity absolute bottom-2 left-0 right-0';
const actionsInner = document.createElement('div'); const actionsInner = document.createElement('div');
actionsInner.className = 'flex items-center gap-1 bg-gray-900/90 rounded px-2 py-1'; actionsInner.className =
'flex items-center gap-1 bg-gray-900/90 rounded px-2 py-1';
actions.appendChild(actionsInner); actions.appendChild(actionsInner);
// Select checkbox // Select checkbox
const selectBtn = document.createElement('button'); const selectBtn = document.createElement('button');
selectBtn.className = 'absolute top-2 right-2 p-1 rounded bg-gray-900/70 hover:bg-gray-800 z-10'; selectBtn.className =
'absolute top-2 right-2 p-1 rounded bg-gray-900/70 hover:bg-gray-800 z-10';
selectBtn.innerHTML = selectedPages.has(index) selectBtn.innerHTML = selectedPages.has(index)
? '<i data-lucide="check-square" class="w-4 h-4 text-indigo-400"></i>' ? '<i data-lucide="check-square" class="w-4 h-4 text-indigo-400"></i>'
: '<i data-lucide="square" class="w-4 h-4 text-gray-200"></i>'; : '<i data-lucide="square" class="w-4 h-4 text-gray-200"></i>';
@@ -538,14 +609,16 @@ function createPageElement(canvas: HTMLCanvasElement | null, index: number): HTM
// Rotate button // Rotate button
const rotateBtn = document.createElement('button'); const rotateBtn = document.createElement('button');
rotateBtn.className = 'p-1 rounded hover:bg-gray-700'; rotateBtn.className = 'p-1 rounded hover:bg-gray-700';
rotateBtn.innerHTML = '<i data-lucide="rotate-cw" class="w-4 h-4 text-gray-300"></i>'; rotateBtn.innerHTML =
'<i data-lucide="rotate-cw" class="w-4 h-4 text-gray-300"></i>';
rotateBtn.onclick = (e) => { rotateBtn.onclick = (e) => {
e.stopPropagation(); e.stopPropagation();
rotatePage(index, 90); rotatePage(index, 90);
}; };
const rotateLeftBtn = document.createElement('button'); const rotateLeftBtn = document.createElement('button');
rotateLeftBtn.className = 'p-1 rounded hover:bg-gray-700'; rotateLeftBtn.className = 'p-1 rounded hover:bg-gray-700';
rotateLeftBtn.innerHTML = '<i data-lucide="rotate-ccw" class="w-4 h-4 text-gray-300"></i>'; rotateLeftBtn.innerHTML =
'<i data-lucide="rotate-ccw" class="w-4 h-4 text-gray-300"></i>';
rotateLeftBtn.onclick = (e) => { rotateLeftBtn.onclick = (e) => {
e.stopPropagation(); e.stopPropagation();
rotatePage(index, -90); rotatePage(index, -90);
@@ -554,7 +627,8 @@ function createPageElement(canvas: HTMLCanvasElement | null, index: number): HTM
// Duplicate button // Duplicate button
const duplicateBtn = document.createElement('button'); const duplicateBtn = document.createElement('button');
duplicateBtn.className = 'p-1 rounded hover:bg-gray-700'; duplicateBtn.className = 'p-1 rounded hover:bg-gray-700';
duplicateBtn.innerHTML = '<i data-lucide="copy" class="w-4 h-4 text-gray-300"></i>'; duplicateBtn.innerHTML =
'<i data-lucide="copy" class="w-4 h-4 text-gray-300"></i>';
duplicateBtn.title = t('multiTool.actions.duplicatePage'); duplicateBtn.title = t('multiTool.actions.duplicatePage');
duplicateBtn.onclick = (e) => { duplicateBtn.onclick = (e) => {
e.stopPropagation(); e.stopPropagation();
@@ -565,7 +639,8 @@ function createPageElement(canvas: HTMLCanvasElement | null, index: number): HTM
// Delete button // Delete button
const deleteBtn = document.createElement('button'); const deleteBtn = document.createElement('button');
deleteBtn.className = 'p-1 rounded hover:bg-gray-700'; deleteBtn.className = 'p-1 rounded hover:bg-gray-700';
deleteBtn.innerHTML = '<i data-lucide="trash-2" class="w-4 h-4 text-red-400"></i>'; deleteBtn.innerHTML =
'<i data-lucide="trash-2" class="w-4 h-4 text-red-400"></i>';
deleteBtn.title = t('multiTool.actions.deletePage'); deleteBtn.title = t('multiTool.actions.deletePage');
deleteBtn.onclick = (e) => { deleteBtn.onclick = (e) => {
e.stopPropagation(); e.stopPropagation();
@@ -576,7 +651,8 @@ function createPageElement(canvas: HTMLCanvasElement | null, index: number): HTM
// Insert PDF button // Insert PDF button
const insertBtn = document.createElement('button'); const insertBtn = document.createElement('button');
insertBtn.className = 'p-1 rounded hover:bg-gray-700'; insertBtn.className = 'p-1 rounded hover:bg-gray-700';
insertBtn.innerHTML = '<i data-lucide="file-plus" class="w-4 h-4 text-gray-300"></i>'; insertBtn.innerHTML =
'<i data-lucide="file-plus" class="w-4 h-4 text-gray-300"></i>';
insertBtn.title = t('multiTool.actions.insertPdf'); insertBtn.title = t('multiTool.actions.insertPdf');
insertBtn.onclick = (e) => { insertBtn.onclick = (e) => {
e.stopPropagation(); e.stopPropagation();
@@ -587,7 +663,8 @@ function createPageElement(canvas: HTMLCanvasElement | null, index: number): HTM
// Split button // Split button
const splitBtn = document.createElement('button'); const splitBtn = document.createElement('button');
splitBtn.className = 'p-1 rounded hover:bg-gray-700'; splitBtn.className = 'p-1 rounded hover:bg-gray-700';
splitBtn.innerHTML = '<i data-lucide="scissors" class="w-4 h-4 text-gray-300"></i>'; splitBtn.innerHTML =
'<i data-lucide="scissors" class="w-4 h-4 text-gray-300"></i>';
splitBtn.title = t('multiTool.actions.toggleSplit'); splitBtn.title = t('multiTool.actions.toggleSplit');
splitBtn.onclick = (e) => { splitBtn.onclick = (e) => {
e.stopPropagation(); e.stopPropagation();
@@ -596,14 +673,23 @@ function createPageElement(canvas: HTMLCanvasElement | null, index: number): HTM
renderSplitMarkers(); renderSplitMarkers();
}; };
actionsInner.append(rotateLeftBtn, rotateBtn, duplicateBtn, insertBtn, splitBtn, deleteBtn); actionsInner.append(
rotateLeftBtn,
rotateBtn,
duplicateBtn,
insertBtn,
splitBtn,
deleteBtn
);
card.append(preview, info, actions, selectBtn); card.append(preview, info, actions, selectBtn);
// Check for split marker // Check for split marker
if (splitMarkers.has(index)) { if (splitMarkers.has(index)) {
const marker = document.createElement('div'); const marker = document.createElement('div');
marker.className = 'split-marker absolute -right-3 top-0 bottom-0 w-6 flex items-center justify-center z-20 pointer-events-none'; marker.className =
marker.innerHTML = '<div class="h-full w-0.5 border-l-2 border-dashed border-blue-400"></div>'; 'split-marker absolute -right-3 top-0 bottom-0 w-6 flex items-center justify-center z-20 pointer-events-none';
marker.innerHTML =
'<div class="h-full w-0.5 border-l-2 border-dashed border-blue-400"></div>';
card.appendChild(marker); card.appendChild(marker);
} }
@@ -655,15 +741,19 @@ function toggleSelectOptimized(index: number) {
const card = pagesContainer.children[index] as HTMLElement; const card = pagesContainer.children[index] as HTMLElement;
if (!card) return; if (!card) return;
const selectBtn = card.querySelector('button[class*="absolute top-2 right-2"]'); const selectBtn = card.querySelector(
'button[class*="absolute top-2 right-2"]'
);
if (!selectBtn) return; if (!selectBtn) return;
if (selectedPages.has(index)) { if (selectedPages.has(index)) {
card.classList.add('border-indigo-500', 'ring-2', 'ring-indigo-500'); card.classList.add('border-indigo-500', 'ring-2', 'ring-indigo-500');
selectBtn.innerHTML = '<i data-lucide="check-square" class="w-4 h-4 text-indigo-400"></i>'; selectBtn.innerHTML =
'<i data-lucide="check-square" class="w-4 h-4 text-indigo-400"></i>';
} else { } else {
card.classList.remove('border-indigo-500', 'ring-2', 'ring-indigo-500'); card.classList.remove('border-indigo-500', 'ring-2', 'ring-indigo-500');
selectBtn.innerHTML = '<i data-lucide="square" class="w-4 h-4 text-gray-200"></i>'; selectBtn.innerHTML =
'<i data-lucide="square" class="w-4 h-4 text-gray-200"></i>';
} }
createIcons({ icons }); createIcons({ icons });
@@ -730,7 +820,7 @@ function deletePage(index: number) {
allPages.splice(index, 1); allPages.splice(index, 1);
selectedPages.delete(index); selectedPages.delete(index);
const newSelected = new Set<number>(); const newSelected = new Set<number>();
selectedPages.forEach(i => { selectedPages.forEach((i) => {
if (i > index) newSelected.add(i - 1); if (i > index) newSelected.add(i - 1);
else if (i < index) newSelected.add(i); else if (i < index) newSelected.add(i);
}); });
@@ -759,13 +849,17 @@ async function handleInsertPdf(e: Event) {
try { try {
const arrayBuffer = await file.arrayBuffer(); const arrayBuffer = await file.arrayBuffer();
const pdfDoc = await PDFLibDocument.load(arrayBuffer, { ignoreEncryption: true, throwOnInvalidObject: false }); const pdfDoc = await PDFLibDocument.load(arrayBuffer, {
ignoreEncryption: true,
throwOnInvalidObject: false,
});
currentPdfDocs.push(pdfDoc); currentPdfDocs.push(pdfDoc);
const pdfIndex = currentPdfDocs.length - 1; const pdfIndex = currentPdfDocs.length - 1;
// Load PDF.js document for rendering // Load PDF.js document for rendering
const pdfBytes = await pdfDoc.save(); const pdfBytes = await pdfDoc.save();
const pdfjsDoc = await getPDFDocument({ data: new Uint8Array(pdfBytes) }).promise; const pdfjsDoc = await getPDFDocument({ data: new Uint8Array(pdfBytes) })
.promise;
const numPages = pdfjsDoc.numPages; const numPages = pdfjsDoc.numPages;
const newPages: PageData[] = []; const newPages: PageData[] = [];
@@ -802,13 +896,18 @@ async function handleInsertPdf(e: Event) {
// Update UI if card exists // Update UI if card exists
const pagesContainer = document.getElementById('pages-container'); const pagesContainer = document.getElementById('pages-container');
const card = pagesContainer?.querySelector(`div[data-page-index="${globalIndex}"]`); const card = pagesContainer?.querySelector(
`div[data-page-index="${globalIndex}"]`
);
if (card) { if (card) {
const preview = card.querySelector('.bg-gray-700') || card.querySelector('.bg-white'); const preview =
card.querySelector('.bg-gray-700') ||
card.querySelector('.bg-white');
if (preview) { if (preview) {
// Re-create the preview content // Re-create the preview content
preview.innerHTML = ''; preview.innerHTML = '';
preview.className = 'bg-white rounded mb-2 overflow-hidden w-full flex items-center justify-center relative h-36 sm:h-64'; preview.className =
'bg-white rounded mb-2 overflow-hidden w-full flex items-center justify-center relative h-36 sm:h-64';
const previewCanvas = canvas; const previewCanvas = canvas;
previewCanvas.className = 'max-w-full max-h-full object-contain'; previewCanvas.className = 'max-w-full max-h-full object-contain';
@@ -819,10 +918,13 @@ async function handleInsertPdf(e: Event) {
} }
} }
} }
} catch (e) { } catch (e) {
console.error('Failed to insert PDF:', e); console.error('Failed to insert PDF:', e);
showModal('Error', 'Failed to insert PDF. The file may be corrupted.', 'error'); showModal(
'Error',
'Failed to insert PDF. The file may be corrupted.',
'error'
);
} }
input.value = ''; input.value = '';
@@ -837,13 +939,15 @@ function renderSplitMarkers() {
const pagesContainer = document.getElementById('pages-container'); const pagesContainer = document.getElementById('pages-container');
if (!pagesContainer) return; if (!pagesContainer) return;
pagesContainer.querySelectorAll('.split-marker').forEach(m => m.remove()); pagesContainer.querySelectorAll('.split-marker').forEach((m) => m.remove());
Array.from(pagesContainer.children).forEach((cardEl, i) => { Array.from(pagesContainer.children).forEach((cardEl, i) => {
if (splitMarkers.has(i)) { if (splitMarkers.has(i)) {
const marker = document.createElement('div'); const marker = document.createElement('div');
marker.className = 'split-marker absolute -right-3 top-0 bottom-0 w-6 flex items-center justify-center z-20 pointer-events-none'; marker.className =
marker.innerHTML = '<div class="h-full w-0.5 border-l-2 border-dashed border-blue-400"></div>'; 'split-marker absolute -right-3 top-0 bottom-0 w-6 flex items-center justify-center z-20 pointer-events-none';
marker.innerHTML =
'<div class="h-full w-0.5 border-l-2 border-dashed border-blue-400"></div>';
(cardEl as HTMLElement).appendChild(marker); (cardEl as HTMLElement).appendChild(marker);
} }
}); });
@@ -881,7 +985,7 @@ function bulkRotate(delta: number) {
return; return;
} }
selectedPages.forEach(index => { selectedPages.forEach((index) => {
const pageData = allPages[index]; const pageData = allPages[index];
if (pageData) { if (pageData) {
// Update state // Update state
@@ -890,7 +994,9 @@ function bulkRotate(delta: number) {
// Update DOM immediately if it exists // Update DOM immediately if it exists
const pagesContainer = document.getElementById('pages-container'); const pagesContainer = document.getElementById('pages-container');
const card = pagesContainer?.querySelector(`div[data-page-index="${index}"]`); const card = pagesContainer?.querySelector(
`div[data-page-index="${index}"]`
);
if (card) { if (card) {
const canvas = card.querySelector('canvas'); const canvas = card.querySelector('canvas');
if (canvas) { if (canvas) {
@@ -911,7 +1017,7 @@ function bulkDelete() {
return; return;
} }
const indices = Array.from(selectedPages).sort((a, b) => b - a); const indices = Array.from(selectedPages).sort((a, b) => b - a);
indices.forEach(index => allPages.splice(index, 1)); indices.forEach((index) => allPages.splice(index, 1));
selectedPages.clear(); selectedPages.clear();
if (allPages.length === 0) { if (allPages.length === 0) {
@@ -928,7 +1034,7 @@ function bulkDuplicate() {
return; return;
} }
const indices = Array.from(selectedPages).sort((a, b) => b - a); const indices = Array.from(selectedPages).sort((a, b) => b - a);
indices.forEach(index => { indices.forEach((index) => {
duplicatePage(index); duplicatePage(index);
}); });
selectedPages.clear(); selectedPages.clear();
@@ -937,11 +1043,15 @@ function bulkDuplicate() {
function bulkSplit() { function bulkSplit() {
if (selectedPages.size === 0) { if (selectedPages.size === 0) {
showModal('No Selection', 'Please select pages to mark for splitting.', 'info'); showModal(
'No Selection',
'Please select pages to mark for splitting.',
'info'
);
return; return;
} }
const indices = Array.from(selectedPages); const indices = Array.from(selectedPages);
indices.forEach(index => { indices.forEach((index) => {
if (!splitMarkers.has(index)) { if (!splitMarkers.has(index)) {
splitMarkers.add(index); splitMarkers.add(index);
} }
@@ -951,7 +1061,6 @@ function bulkSplit() {
updatePageDisplay(); updatePageDisplay();
} }
async function downloadAll() { async function downloadAll() {
if (allPages.length === 0) { if (allPages.length === 0) {
showModal('No Pages', 'Please upload PDFs first.', 'info'); showModal('No Pages', 'Please upload PDFs first.', 'info');
@@ -993,24 +1102,63 @@ async function downloadSplitPdfs() {
segments.push(currentSegment); segments.push(currentSegment);
} }
// Create PDFs for each segment
for (let segIndex = 0; segIndex < segments.length; segIndex++) { for (let segIndex = 0; segIndex < segments.length; segIndex++) {
const segment = segments[segIndex]; const segment = segments[segIndex];
const newPdf = await PDFLibDocument.create(); const newPdf = await PDFLibDocument.create();
const segSpecs: (
| {
type: 'pdf';
pdfDoc: PDFLibDocument;
originalPageIndex: number;
rotation: number;
}
| { type: 'blank' }
)[] = [];
for (const index of segment) { for (const index of segment) {
const pageData = allPages[index]; const pageData = allPages[index];
if (!pageData) { if (!pageData) continue;
console.warn(`Page data missing for index ${index}`);
continue;
}
if (pageData.pdfDoc && pageData.originalPageIndex >= 0) { if (pageData.pdfDoc && pageData.originalPageIndex >= 0) {
const [copiedPage] = await newPdf.copyPages(pageData.pdfDoc, [pageData.originalPageIndex]); segSpecs.push({
const page = newPdf.addPage(copiedPage); type: 'pdf',
pdfDoc: pageData.pdfDoc,
originalPageIndex: pageData.originalPageIndex,
rotation: pageData.rotation,
});
} else {
segSpecs.push({ type: 'blank' });
}
}
if (pageData.rotation !== 0) { const docPageIndices = new Map<PDFLibDocument, number[]>();
for (const spec of segSpecs) {
if (spec.type === 'pdf') {
if (!docPageIndices.has(spec.pdfDoc)) {
docPageIndices.set(spec.pdfDoc, []);
}
docPageIndices.get(spec.pdfDoc)!.push(spec.originalPageIndex);
}
}
const copiedPagesMap = new Map<PDFLibDocument, PDFPage[]>();
for (const [doc, pageIdxs] of Array.from(docPageIndices)) {
const copied = await newPdf.copyPages(doc, pageIdxs);
copiedPagesMap.set(doc, copied);
}
const docConsumeIndex = new Map<PDFLibDocument, number>();
docPageIndices.forEach((_, doc) => docConsumeIndex.set(doc, 0));
for (const spec of segSpecs) {
if (spec.type === 'pdf') {
const idx = docConsumeIndex.get(spec.pdfDoc)!;
const copiedPage = copiedPagesMap.get(spec.pdfDoc)![idx];
docConsumeIndex.set(spec.pdfDoc, idx + 1);
const page = newPdf.addPage(copiedPage);
if (spec.rotation !== 0) {
const currentRotation = page.getRotation().angle; const currentRotation = page.getRotation().angle;
page.setRotation(degrees(currentRotation + pageData.rotation)); page.setRotation(degrees(currentRotation + spec.rotation));
} }
} else { } else {
newPdf.addPage([595, 842]); newPdf.addPage([595, 842]);
@@ -1025,7 +1173,11 @@ async function downloadSplitPdfs() {
const zipBlob = await zip.generateAsync({ type: 'blob' }); const zipBlob = await zip.generateAsync({ type: 'blob' });
downloadFile(zipBlob, 'split-documents.zip'); downloadFile(zipBlob, 'split-documents.zip');
showModal('Success', `Downloaded ${segments.length} PDF files in a ZIP archive.`, 'success'); showModal(
'Success',
`Downloaded ${segments.length} PDF files in a ZIP archive.`,
'success'
);
} catch (e) { } catch (e) {
console.error('Failed to create split PDFs:', e); console.error('Failed to create split PDFs:', e);
showModal('Error', 'Failed to create split PDFs.', 'error'); showModal('Error', 'Failed to create split PDFs.', 'error');
@@ -1038,20 +1190,59 @@ async function downloadPagesAsPdf(indices: number[], filename: string) {
try { try {
const newPdf = await PDFLibDocument.create(); const newPdf = await PDFLibDocument.create();
const pageSpecs: (
| {
type: 'pdf';
pdfDoc: PDFLibDocument;
originalPageIndex: number;
rotation: number;
}
| { type: 'blank' }
)[] = [];
for (const index of indices) { for (const index of indices) {
const pageData = allPages[index]; const pageData = allPages[index];
if (!pageData) { if (!pageData) continue;
console.warn(`Page data missing for index ${index}`);
continue;
}
if (pageData.pdfDoc && pageData.originalPageIndex >= 0) { if (pageData.pdfDoc && pageData.originalPageIndex >= 0) {
// Copy page from original PDF pageSpecs.push({
const [copiedPage] = await newPdf.copyPages(pageData.pdfDoc, [pageData.originalPageIndex]); type: 'pdf',
const page = newPdf.addPage(copiedPage); pdfDoc: pageData.pdfDoc,
originalPageIndex: pageData.originalPageIndex,
rotation: pageData.rotation,
});
} else {
pageSpecs.push({ type: 'blank' });
}
}
if (pageData.rotation !== 0) { const docPageIndices = new Map<PDFLibDocument, number[]>();
for (const spec of pageSpecs) {
if (spec.type === 'pdf') {
if (!docPageIndices.has(spec.pdfDoc)) {
docPageIndices.set(spec.pdfDoc, []);
}
docPageIndices.get(spec.pdfDoc)!.push(spec.originalPageIndex);
}
}
const copiedPagesMap = new Map<PDFLibDocument, PDFPage[]>();
for (const [doc, pageIdxs] of Array.from(docPageIndices)) {
const copied = await newPdf.copyPages(doc, pageIdxs);
copiedPagesMap.set(doc, copied);
}
const docConsumeIndex = new Map<PDFLibDocument, number>();
docPageIndices.forEach((_, doc) => docConsumeIndex.set(doc, 0));
for (const spec of pageSpecs) {
if (spec.type === 'pdf') {
const idx = docConsumeIndex.get(spec.pdfDoc)!;
const copiedPage = copiedPagesMap.get(spec.pdfDoc)![idx];
docConsumeIndex.set(spec.pdfDoc, idx + 1);
const page = newPdf.addPage(copiedPage);
if (spec.rotation !== 0) {
const currentRotation = page.getRotation().angle; const currentRotation = page.getRotation().angle;
page.setRotation(degrees(currentRotation + pageData.rotation)); page.setRotation(degrees(currentRotation + spec.rotation));
} }
} else { } else {
newPdf.addPage([595, 842]); newPdf.addPage([595, 842]);
@@ -1059,7 +1250,9 @@ async function downloadPagesAsPdf(indices: number[], filename: string) {
} }
const pdfBytes = await newPdf.save(); const pdfBytes = await newPdf.save();
const blob = new Blob([new Uint8Array(pdfBytes)], { type: 'application/pdf' }); const blob = new Blob([new Uint8Array(pdfBytes)], {
type: 'application/pdf',
});
downloadFile(blob, filename); downloadFile(blob, filename);
showModal('Success', 'PDF downloaded successfully.', 'success'); showModal('Success', 'PDF downloaded successfully.', 'success');
@@ -1101,18 +1294,28 @@ function updatePageDisplay() {
// Update index-dependent attributes // Update index-dependent attributes
card.dataset.pageIndex = index.toString(); card.dataset.pageIndex = index.toString();
const info = card.querySelector('.text-xs.text-gray-400.text-center.mb-2'); const info = card.querySelector(
'.text-xs.text-gray-400.text-center.mb-2'
);
if (info) info.textContent = `Page ${index + 1} `; if (info) info.textContent = `Page ${index + 1} `;
// Update selection state // Update selection state
const selectBtn = card.querySelector('button[class*="absolute top-2 right-2"]'); const selectBtn = card.querySelector(
'button[class*="absolute top-2 right-2"]'
);
if (selectBtn) { if (selectBtn) {
if (selectedPages.has(index)) { if (selectedPages.has(index)) {
card.classList.add('border-indigo-500', 'ring-2', 'ring-indigo-500'); card.classList.add('border-indigo-500', 'ring-2', 'ring-indigo-500');
selectBtn.innerHTML = '<i data-lucide="check-square" class="w-4 h-4 text-indigo-400"></i>'; selectBtn.innerHTML =
'<i data-lucide="check-square" class="w-4 h-4 text-indigo-400"></i>';
} else { } else {
card.classList.remove('border-indigo-500', 'ring-2', 'ring-indigo-500'); card.classList.remove(
selectBtn.innerHTML = '<i data-lucide="square" class="w-4 h-4 text-gray-200"></i>'; 'border-indigo-500',
'ring-2',
'ring-indigo-500'
);
selectBtn.innerHTML =
'<i data-lucide="square" class="w-4 h-4 text-gray-200"></i>';
} }
// Update click handler to use new index // Update click handler to use new index
(selectBtn as HTMLElement).onclick = (e) => { (selectBtn as HTMLElement).onclick = (e) => {
@@ -1128,17 +1331,47 @@ function updatePageDisplay() {
} }
// Update action buttons // Update action buttons
const actionsInner = card.querySelector('.flex.items-center.gap-1.bg-gray-900\\/90'); const actionsInner = card.querySelector(
'.flex.items-center.gap-1.bg-gray-900\\/90'
);
if (actionsInner) { if (actionsInner) {
const buttons = actionsInner.querySelectorAll('button'); const buttons = actionsInner.querySelectorAll('button');
if (buttons[0]) (buttons[0] as HTMLElement).onclick = (e) => { e.stopPropagation(); rotatePage(index, -90); }; if (buttons[0])
if (buttons[1]) (buttons[1] as HTMLElement).onclick = (e) => { e.stopPropagation(); rotatePage(index, 90); }; (buttons[0] as HTMLElement).onclick = (e) => {
if (buttons[2]) (buttons[2] as HTMLElement).onclick = (e) => { e.stopPropagation(); snapshot(); duplicatePage(index); }; e.stopPropagation();
if (buttons[3]) (buttons[3] as HTMLElement).onclick = (e) => { e.stopPropagation(); snapshot(); insertPdfAfter(index); }; rotatePage(index, -90);
if (buttons[4]) (buttons[4] as HTMLElement).onclick = (e) => { e.stopPropagation(); snapshot(); toggleSplitMarker(index); renderSplitMarkers(); }; };
if (buttons[5]) (buttons[5] as HTMLElement).onclick = (e) => { e.stopPropagation(); snapshot(); deletePage(index); }; if (buttons[1])
(buttons[1] as HTMLElement).onclick = (e) => {
e.stopPropagation();
rotatePage(index, 90);
};
if (buttons[2])
(buttons[2] as HTMLElement).onclick = (e) => {
e.stopPropagation();
snapshot();
duplicatePage(index);
};
if (buttons[3])
(buttons[3] as HTMLElement).onclick = (e) => {
e.stopPropagation();
snapshot();
insertPdfAfter(index);
};
if (buttons[4])
(buttons[4] as HTMLElement).onclick = (e) => {
e.stopPropagation();
snapshot();
toggleSplitMarker(index);
renderSplitMarkers();
};
if (buttons[5])
(buttons[5] as HTMLElement).onclick = (e) => {
e.stopPropagation();
snapshot();
deletePage(index);
};
} }
} else { } else {
// Element doesn't exist, create it // Element doesn't exist, create it
card = createPageElement(pageData.canvas, index); card = createPageElement(pageData.canvas, index);
@@ -1179,7 +1412,9 @@ function updatePageNumbers() {
// We need to find the buttons and update their onclick handlers // We need to find the buttons and update their onclick handlers
// This is necessary because the original handlers captured the old index // This is necessary because the original handlers captured the old index
const selectBtn = card.querySelector('button[class*="absolute top-2 right-2"]') as HTMLButtonElement; const selectBtn = card.querySelector(
'button[class*="absolute top-2 right-2"]'
) as HTMLButtonElement;
if (selectBtn) { if (selectBtn) {
selectBtn.onclick = (e) => { selectBtn.onclick = (e) => {
e.stopPropagation(); e.stopPropagation();
@@ -1187,16 +1422,47 @@ function updatePageNumbers() {
}; };
} }
const actionsInner = card.querySelector('.flex.items-center.gap-1.bg-gray-900\\/90'); const actionsInner = card.querySelector(
'.flex.items-center.gap-1.bg-gray-900\\/90'
);
if (actionsInner) { if (actionsInner) {
const buttons = actionsInner.querySelectorAll('button'); const buttons = actionsInner.querySelectorAll('button');
// Order: Rotate Left, Rotate Right, Duplicate, Insert, Split, Delete // Order: Rotate Left, Rotate Right, Duplicate, Insert, Split, Delete
if (buttons[0]) buttons[0].onclick = (e) => { e.stopPropagation(); rotatePage(index, -90); }; if (buttons[0])
if (buttons[1]) buttons[1].onclick = (e) => { e.stopPropagation(); rotatePage(index, 90); }; buttons[0].onclick = (e) => {
if (buttons[2]) buttons[2].onclick = (e) => { e.stopPropagation(); snapshot(); duplicatePage(index); }; e.stopPropagation();
if (buttons[3]) buttons[3].onclick = (e) => { e.stopPropagation(); snapshot(); insertPdfAfter(index); }; rotatePage(index, -90);
if (buttons[4]) buttons[4].onclick = (e) => { e.stopPropagation(); snapshot(); toggleSplitMarker(index); renderSplitMarkers(); }; };
if (buttons[5]) buttons[5].onclick = (e) => { e.stopPropagation(); snapshot(); deletePage(index); }; if (buttons[1])
buttons[1].onclick = (e) => {
e.stopPropagation();
rotatePage(index, 90);
};
if (buttons[2])
buttons[2].onclick = (e) => {
e.stopPropagation();
snapshot();
duplicatePage(index);
};
if (buttons[3])
buttons[3].onclick = (e) => {
e.stopPropagation();
snapshot();
insertPdfAfter(index);
};
if (buttons[4])
buttons[4].onclick = (e) => {
e.stopPropagation();
snapshot();
toggleSplitMarker(index);
renderSplitMarkers();
};
if (buttons[5])
buttons[5].onclick = (e) => {
e.stopPropagation();
snapshot();
deletePage(index);
};
} }
}); });
} }
+17 -10
View File
@@ -2,7 +2,11 @@ import { showAlert } from '../ui.js';
import { tesseractLanguages } from '../config/tesseract-languages.js'; import { tesseractLanguages } from '../config/tesseract-languages.js';
import { createWorkflowEditor, updateNodeDisplay } from '../workflow/editor'; import { createWorkflowEditor, updateNodeDisplay } from '../workflow/editor';
import { executeWorkflow } from '../workflow/engine'; import { executeWorkflow } from '../workflow/engine';
import { nodeRegistry, getNodesByCategory } from '../workflow/nodes/registry'; import {
nodeRegistry,
getNodesByCategory,
createNodeByType,
} from '../workflow/nodes/registry';
import type { BaseWorkflowNode } from '../workflow/nodes/base-node'; import type { BaseWorkflowNode } from '../workflow/nodes/base-node';
import type { WorkflowEditor } from '../workflow/editor'; import type { WorkflowEditor } from '../workflow/editor';
import { import {
@@ -43,6 +47,7 @@ import {
let workflowEditor: WorkflowEditor | null = null; let workflowEditor: WorkflowEditor | null = null;
let selectedNodeId: string | null = null; let selectedNodeId: string | null = null;
let deleteNodeHandler: EventListener | null = null;
if (document.readyState === 'loading') { if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', initializePage); document.addEventListener('DOMContentLoaded', initializePage);
@@ -245,10 +250,14 @@ async function initializePage() {
} }
}); });
document.addEventListener('wf-delete-node', ((e: CustomEvent) => { if (deleteNodeHandler) {
document.removeEventListener('wf-delete-node', deleteNodeHandler);
}
deleteNodeHandler = ((e: CustomEvent) => {
const nodeId = e.detail?.nodeId; const nodeId = e.detail?.nodeId;
if (nodeId) deleteNodeById(nodeId); if (nodeId) deleteNodeById(nodeId);
}) as EventListener); }) as EventListener;
document.addEventListener('wf-delete-node', deleteNodeHandler);
} }
async function deleteNodeById(nodeId: string) { async function deleteNodeById(nodeId: string) {
@@ -539,14 +548,12 @@ async function addNodeToCanvas(
if (!workflowEditor) return; if (!workflowEditor) return;
const { editor, area } = workflowEditor; const { editor, area } = workflowEditor;
const entry = nodeRegistry[type];
if (!entry) {
console.error('Node type not found in registry:', type);
return;
}
try { try {
const node = entry.factory(); const node = createNodeByType(type);
if (!node) {
console.error('Node type not found in registry:', type);
return;
}
await editor.addNode(node); await editor.addNode(node);
const pos = position || getCanvasCenter(area); const pos = position || getCanvasCenter(area);
+111 -10
View File
@@ -15,6 +15,7 @@ import {
createLanguageSwitcher, createLanguageSwitcher,
t, t,
} from './i18n/index.js'; } from './i18n/index.js';
declare const __BRAND_NAME__: string;
const init = async () => { const init = async () => {
await initI18n(); await initI18n();
@@ -81,18 +82,19 @@ const init = async () => {
(divider as HTMLElement).style.display = 'none'; (divider as HTMLElement).style.display = 'none';
}); });
document.title = 'BentoPDF - PDF Tools'; const brandName = __BRAND_NAME__ || 'BentoPDF';
document.title = `${brandName} - ${t('simpleMode.title')}`;
const toolsHeader = document.getElementById('tools-header'); const toolsHeader = document.getElementById('tools-header');
if (toolsHeader) { if (toolsHeader) {
const title = toolsHeader.querySelector('h2'); const title = toolsHeader.querySelector('h2');
const subtitle = toolsHeader.querySelector('p'); const subtitle = toolsHeader.querySelector('p');
if (title) { if (title) {
title.textContent = 'PDF Tools'; title.textContent = t('simpleMode.title');
title.className = 'text-4xl md:text-5xl font-bold text-white mb-3'; title.className = 'text-4xl md:text-5xl font-bold text-white mb-3';
} }
if (subtitle) { if (subtitle) {
subtitle.textContent = 'Select a tool to get started'; subtitle.textContent = t('simpleMode.subtitle');
subtitle.className = 'text-lg text-gray-400'; subtitle.className = 'text-lg text-gray-400';
} }
} }
@@ -136,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',
@@ -156,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',
@@ -253,19 +257,78 @@ const init = async () => {
if (dom.toolGrid) { if (dom.toolGrid) {
dom.toolGrid.textContent = ''; dom.toolGrid.textContent = '';
let collapsedCategories: string[] = [];
try {
const stored = localStorage.getItem('collapsedCategories');
if (stored) collapsedCategories = JSON.parse(stored);
} catch {
localStorage.removeItem('collapsedCategories');
}
function saveCollapsedCategories() {
localStorage.setItem(
'collapsedCategories',
JSON.stringify(collapsedCategories)
);
}
categories.forEach((category) => { categories.forEach((category) => {
const categoryGroup = document.createElement('div'); const categoryGroup = document.createElement('div');
categoryGroup.className = 'category-group col-span-full'; categoryGroup.className = 'category-group col-span-full';
const title = document.createElement('h2'); const header = document.createElement('button');
title.className = header.className = 'category-header';
'text-xl font-bold text-indigo-400 mb-4 mt-8 first:mt-0 text-white'; header.type = 'button';
const title = document.createElement('span');
const categoryKey = categoryTranslationKeys[category.name]; const categoryKey = categoryTranslationKeys[category.name];
title.textContent = categoryKey ? t(categoryKey) : category.name; title.textContent = categoryKey ? t(categoryKey) : category.name;
const chevron = document.createElement('i');
chevron.setAttribute('data-lucide', 'chevron-down');
chevron.className =
'category-chevron w-5 h-5 text-gray-400 transition-transform duration-300';
header.append(title, chevron);
const toolsContainer = document.createElement('div'); const toolsContainer = document.createElement('div');
toolsContainer.className = toolsContainer.className =
'grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-4 md:gap-6'; 'category-tools grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-4 md:gap-6';
const isCollapsed = collapsedCategories.includes(category.name);
if (isCollapsed) {
categoryGroup.classList.add('collapsed');
toolsContainer.style.maxHeight = '0px';
}
toolsContainer.addEventListener('transitionend', (e) => {
if ((e as TransitionEvent).propertyName !== 'max-height') return;
if (!categoryGroup.classList.contains('collapsed')) {
toolsContainer.style.maxHeight = 'none';
toolsContainer.style.overflow = 'visible';
}
});
header.addEventListener('click', () => {
const collapsed = categoryGroup.classList.toggle('collapsed');
if (collapsed) {
toolsContainer.style.maxHeight = toolsContainer.scrollHeight + 'px';
toolsContainer.style.overflow = 'hidden';
requestAnimationFrame(() => {
toolsContainer.style.maxHeight = '0px';
});
if (!collapsedCategories.includes(category.name)) {
collapsedCategories.push(category.name);
}
} else {
toolsContainer.style.overflow = 'hidden';
toolsContainer.style.maxHeight = toolsContainer.scrollHeight + 'px';
collapsedCategories = collapsedCategories.filter(
(n) => n !== category.name
);
}
saveCollapsedCategories();
});
category.tools.forEach((tool) => { category.tools.forEach((tool) => {
let toolCard: HTMLDivElement | HTMLAnchorElement; let toolCard: HTMLDivElement | HTMLAnchorElement;
@@ -310,8 +373,13 @@ const init = async () => {
toolsContainer.appendChild(toolCard); toolsContainer.appendChild(toolCard);
}); });
categoryGroup.append(title, toolsContainer); categoryGroup.append(header, toolsContainer);
dom.toolGrid.appendChild(categoryGroup); dom.toolGrid.appendChild(categoryGroup);
if (!isCollapsed) {
toolsContainer.style.maxHeight = 'none';
toolsContainer.style.overflow = 'visible';
}
}); });
const searchBar = document.getElementById('search-bar'); const searchBar = document.getElementById('search-bar');
@@ -545,6 +613,35 @@ const init = async () => {
}); });
} }
const compactModeToggle = document.getElementById(
'compact-mode-toggle'
) as HTMLInputElement;
const savedCompactMode = localStorage.getItem('compactMode') === 'true';
if (compactModeToggle) {
compactModeToggle.checked = savedCompactMode;
}
applyCompactMode(savedCompactMode);
function applyCompactMode(enabled: boolean) {
if (dom.toolGrid) {
dom.toolGrid.classList.toggle('compact-mode', enabled);
dom.toolGrid
.querySelectorAll('.category-group:not(.collapsed) .category-tools')
.forEach((container) => {
(container as HTMLElement).style.maxHeight = 'none';
});
}
}
if (compactModeToggle) {
compactModeToggle.addEventListener('change', (e) => {
const enabled = (e.target as HTMLInputElement).checked;
localStorage.setItem('compactMode', enabled.toString());
applyCompactMode(enabled);
});
}
// Shortcuts UI Handlers // Shortcuts UI Handlers
if (dom.openShortcutsBtn) { if (dom.openShortcutsBtn) {
dom.openShortcutsBtn.addEventListener('click', () => { dom.openShortcutsBtn.addEventListener('click', () => {
@@ -805,8 +902,12 @@ const init = async () => {
left.className = 'flex items-center gap-3'; left.className = 'flex items-center gap-3';
const icon = document.createElement('i'); const icon = document.createElement('i');
icon.className = 'w-5 h-5 text-indigo-400'; if (tool.icon.startsWith('ph-')) {
icon.setAttribute('data-lucide', tool.icon); icon.className = `ph ${tool.icon} w-5 h-5 text-indigo-400`;
} else {
icon.className = 'w-5 h-5 text-indigo-400';
icon.setAttribute('data-lucide', tool.icon);
}
const name = document.createElement('span'); const name = document.createElement('span');
name.className = 'text-gray-200 font-medium'; name.className = 'text-gray-200 font-medium';
+6 -2
View File
@@ -1,6 +1,10 @@
import { PDFDocument as PDFLibDocument } from 'pdf-lib'; import { PDFDocument as PDFLibDocument } from 'pdf-lib';
export interface AddWatermarkState { export interface AddWatermarkState {
file: File | null; file: File | null;
pdfDoc: PDFLibDocument | null; pdfDoc: PDFLibDocument | null;
pdfBytes: Uint8Array | null;
previewCanvas: HTMLCanvasElement | null;
watermarkX: number; // 0–1, percentage from left
watermarkY: number; // 0–1, percentage from top (flipped to bottom for PDF)
} }
+17
View File
@@ -0,0 +1,17 @@
export interface StylePreset {
template: string;
padding: number;
}
export type Position =
| 'bottom-center'
| 'bottom-left'
| 'bottom-right'
| 'top-center'
| 'top-left'
| 'top-right';
export interface FileEntry {
file: File;
pageCount: number;
}
+1
View File
@@ -49,3 +49,4 @@ export * from './email-to-pdf-type.ts';
export * from './bookmark-pdf-type.ts'; 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';
+12 -15
View File
@@ -83,25 +83,22 @@ export async function performCondenseCompression(
try { try {
const result = await pymupdf.compressPdf(fileBlob, options); const result = await pymupdf.compressPdf(fileBlob, options);
return result; return result;
} catch (error: any) { } catch {
const errorMessage = error?.message || String(error); const fallbackOptions = {
if ( ...options,
errorMessage.includes('PatternType') || images: {
errorMessage.includes('pattern') ...options.images,
) { enabled: false,
const fallbackOptions = { },
...options, };
images: {
...options.images,
enabled: false,
},
};
try {
const result = await pymupdf.compressPdf(fileBlob, fallbackOptions); const result = await pymupdf.compressPdf(fileBlob, fallbackOptions);
return { ...result, usedFallback: true }; return { ...result, usedFallback: true };
} catch (fallbackError: any) {
const msg = fallbackError?.message || String(fallbackError);
throw new Error(`PDF compression failed: ${msg}`);
} }
throw new Error(`PDF compression failed: ${errorMessage}`);
} }
} }
+72 -14
View File
@@ -187,6 +187,9 @@ export interface TextWatermarkOptions {
color: { r: number; g: number; b: number }; color: { r: number; g: number; b: number };
opacity: number; opacity: number;
angle: number; angle: number;
x?: number;
y?: number;
pageIndices?: number[];
} }
export async function addTextWatermark( export async function addTextWatermark(
@@ -194,19 +197,60 @@ export async function addTextWatermark(
options: TextWatermarkOptions options: TextWatermarkOptions
): Promise<Uint8Array> { ): Promise<Uint8Array> {
const pdfDoc = await PDFDocument.load(pdfBytes); const pdfDoc = await PDFDocument.load(pdfBytes);
const font = await pdfDoc.embedFont(StandardFonts.Helvetica);
const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d');
if (!ctx) throw new Error('Failed to create canvas context');
const dpr = 2;
const colorR = Math.round(options.color.r * 255);
const colorG = Math.round(options.color.g * 255);
const colorB = Math.round(options.color.b * 255);
const fontStr = `bold ${options.fontSize * dpr}px "Noto Sans SC", "Noto Sans JP", "Noto Sans KR", "Noto Sans Arabic", Arial, sans-serif`;
ctx.font = fontStr;
const metrics = ctx.measureText(options.text);
canvas.width = Math.ceil(metrics.width) + 4;
canvas.height = Math.ceil(options.fontSize * dpr * 1.4);
ctx.font = fontStr;
ctx.fillStyle = `rgb(${colorR}, ${colorG}, ${colorB})`;
ctx.textBaseline = 'middle';
ctx.fillText(options.text, 2, canvas.height / 2);
const blob = await new Promise<Blob>((resolve, reject) => {
canvas.toBlob(
(b) => (b ? resolve(b) : reject(new Error('Canvas toBlob failed'))),
'image/png'
);
});
const imageBytes = new Uint8Array(await blob.arrayBuffer());
const image = await pdfDoc.embedPng(imageBytes);
const pages = pdfDoc.getPages(); const pages = pdfDoc.getPages();
const posX = options.x ?? 0.5;
const posY = options.y ?? 0.5;
const imgWidth = image.width / dpr;
const imgHeight = image.height / dpr;
for (const page of pages) { const rad = (options.angle * Math.PI) / 180;
const halfW = imgWidth / 2;
const halfH = imgHeight / 2;
const targetIndices = options.pageIndices ?? pages.map((_, i) => i);
for (const idx of targetIndices) {
const page = pages[idx];
if (!page) continue;
const { width, height } = page.getSize(); const { width, height } = page.getSize();
const textWidth = font.widthOfTextAtSize(options.text, options.fontSize); const cx = posX * width;
const cy = posY * height;
page.drawText(options.text, { page.drawImage(image, {
x: (width - textWidth) / 2, x: cx - Math.cos(rad) * halfW + Math.sin(rad) * halfH,
y: height / 2, y: cy - Math.sin(rad) * halfW - Math.cos(rad) * halfH,
font, width: imgWidth,
size: options.fontSize, height: imgHeight,
color: rgb(options.color.r, options.color.g, options.color.b),
opacity: options.opacity, opacity: options.opacity,
rotate: degrees(options.angle), rotate: degrees(options.angle),
}); });
@@ -221,6 +265,9 @@ export interface ImageWatermarkOptions {
opacity: number; opacity: number;
angle: number; angle: number;
scale: number; scale: number;
x?: number;
y?: number;
pageIndices?: number[];
} }
export async function addImageWatermark( export async function addImageWatermark(
@@ -233,15 +280,26 @@ export async function addImageWatermark(
? await pdfDoc.embedPng(options.imageBytes) ? await pdfDoc.embedPng(options.imageBytes)
: await pdfDoc.embedJpg(options.imageBytes); : await pdfDoc.embedJpg(options.imageBytes);
const pages = pdfDoc.getPages(); const pages = pdfDoc.getPages();
const posX = options.x ?? 0.5;
const posY = options.y ?? 0.5;
for (const page of pages) { const imgWidth = image.width * options.scale;
const imgHeight = image.height * options.scale;
const rad = (options.angle * Math.PI) / 180;
const halfW = imgWidth / 2;
const halfH = imgHeight / 2;
const targetIndices = options.pageIndices ?? pages.map((_, i) => i);
for (const idx of targetIndices) {
const page = pages[idx];
if (!page) continue;
const { width, height } = page.getSize(); const { width, height } = page.getSize();
const imgWidth = image.width * options.scale; const cx = posX * width;
const imgHeight = image.height * options.scale; const cy = posY * height;
page.drawImage(image, { page.drawImage(image, {
x: (width - imgWidth) / 2, x: cx - Math.cos(rad) * halfW + Math.sin(rad) * halfH,
y: (height - imgHeight) / 2, y: cy - Math.sin(rad) * halfW - Math.cos(rad) * halfH,
width: imgWidth, width: imgWidth,
height: imgHeight, height: imgHeight,
opacity: options.opacity, opacity: options.opacity,
+51 -28
View File
@@ -103,7 +103,10 @@ export async function renderPageToCanvas(
canvas.height = viewport.height; canvas.height = viewport.height;
canvas.width = viewport.width; canvas.width = viewport.width;
const context = canvas.getContext('2d')!; const context = canvas.getContext('2d');
if (!context) {
throw new Error(`Failed to get 2D context for page ${pageNumber}`);
}
await page.render({ await page.render({
canvasContext: context, canvasContext: context,
@@ -115,12 +118,9 @@ export async function renderPageToCanvas(
} }
/** /**
* Renders a batch of pages in parallel * Renders a batch of pages
*/ */
async function renderPageBatch( async function renderPageBatch(tasks: PageTask[]): Promise<void> {
tasks: PageTask[],
onProgress?: (current: number, total: number) => void
): Promise<void> {
for (const task of tasks) { for (const task of tasks) {
try { try {
const canvas = await renderPageToCanvas( const canvas = await renderPageToCanvas(
@@ -147,6 +147,13 @@ async function renderPageBatch(
parent.insertBefore(wrapper, placeholder); parent.insertBefore(wrapper, placeholder);
parent.removeChild(placeholder); parent.removeChild(placeholder);
} else { } else {
const existingRendered = task.container.querySelector(
`[data-page-number="${task.pageNumber}"]:not([data-lazy-load="true"])`
);
if (existingRendered) {
continue;
}
const allChildren = Array.from( const allChildren = Array.from(
task.container.children task.container.children
) as HTMLElement[]; ) as HTMLElement[];
@@ -207,7 +214,7 @@ function setupLazyRendering(
task.placeholderElement = placeholder; task.placeholderElement = placeholder;
// Render this page immediately (not waiting for isRendering flag) // Render this page immediately (not waiting for isRendering flag)
renderPageBatch([task], config.onProgress) renderPageBatch([task])
.then(() => { .then(() => {
// Trigger callback after lazy load batch // Trigger callback after lazy load batch
if (config.onBatchComplete) { if (config.onBatchComplete) {
@@ -263,21 +270,19 @@ export async function renderPagesProgressively(
config: RenderConfig = {} config: RenderConfig = {}
): Promise<void> { ): Promise<void> {
const { const {
batchSize = 8, // Increased from 5 to 8 for faster initial render batchSize = 8,
useLazyLoading = true, useLazyLoading = true,
eagerLoadBatches = 2, // Eagerly load 1 batch ahead by default eagerLoadBatches = 2,
onProgress, onProgress,
onBatchComplete, onBatchComplete,
} = config; } = config;
const totalPages = pdfjsDoc.numPages; const totalPages = pdfjsDoc.numPages;
// Render more pages initially to reduce lazy loading issues
const initialRenderCount = useLazyLoading const initialRenderCount = useLazyLoading
? Math.min(20, totalPages) // Increased from 12 to 20 pages ? Math.min(20, totalPages)
: totalPages; : totalPages;
// CRITICAL FIX: Create placeholders for ALL pages first to maintain order
const placeholders: HTMLElement[] = []; const placeholders: HTMLElement[] = [];
for (let i = 1; i <= totalPages; i++) { for (let i = 1; i <= totalPages; i++) {
const placeholder = createPlaceholder(i); const placeholder = createPlaceholder(i);
@@ -293,7 +298,7 @@ export async function renderPagesProgressively(
pageNumber: i, pageNumber: i,
pdfjsDoc, pdfjsDoc,
container, container,
scale: config.useLazyLoading ? 0.3 : 0.5, scale: useLazyLoading ? 0.3 : 0.5,
createWrapper, createWrapper,
placeholderElement: placeholders[i - 1], placeholderElement: placeholders[i - 1],
}); });
@@ -330,19 +335,24 @@ export async function renderPagesProgressively(
const batch = initialTasks.slice(i, i + batchSize); const batch = initialTasks.slice(i, i + batchSize);
await new Promise<void>((resolve) => { await new Promise<void>((resolve, reject) => {
requestIdleCallbackPolyfill(async () => { requestIdleCallbackPolyfill(() => {
await renderPageBatch(batch, onProgress); renderPageBatch(batch)
.then(() => {
if (onProgress) {
onProgress(
Math.min(i + batchSize, initialRenderCount),
totalPages
);
}
if (onProgress) { if (onBatchComplete) {
onProgress(Math.min(i + batchSize, initialRenderCount), totalPages); onBatchComplete();
} }
if (onBatchComplete) { resolve();
onBatchComplete(); })
} .catch(reject);
resolve();
}); });
}); });
} }
@@ -397,8 +407,11 @@ function renderEagerBatch(config: RenderConfig): void {
requestIdleCallbackPolyfill(async () => { requestIdleCallbackPolyfill(async () => {
if (config.shouldCancel?.()) return; if (config.shouldCancel?.()) return;
// Remove these tasks from pending since we're rendering them eagerly const tasksToRender = batch.filter((task) =>
batch.forEach((task) => { lazyLoadState.pendingTasksByPageNumber.has(task.pageNumber)
);
tasksToRender.forEach((task) => {
const placeholder = task.placeholderElement; const placeholder = task.placeholderElement;
if (placeholder && lazyLoadState.observer) { if (placeholder && lazyLoadState.observer) {
lazyLoadState.observer.unobserve(placeholder); lazyLoadState.observer.unobserve(placeholder);
@@ -407,7 +420,18 @@ function renderEagerBatch(config: RenderConfig): void {
} }
}); });
await renderPageBatch(batch, config.onProgress); if (tasksToRender.length === 0) {
lazyLoadState.nextEagerIndex = batchEnd;
const remainingBatches = Math.ceil(
(eagerLoadQueue.length - batchEnd) / batchSize
);
if (remainingBatches > 0 && remainingBatches < eagerLoadBatches) {
renderEagerBatch(config);
}
return;
}
await renderPageBatch(tasksToRender);
if (config.onBatchComplete) { if (config.onBatchComplete) {
config.onBatchComplete(); config.onBatchComplete();
@@ -421,7 +445,6 @@ function renderEagerBatch(config: RenderConfig): void {
(eagerLoadQueue.length - batchEnd) / batchSize (eagerLoadQueue.length - batchEnd) / batchSize
); );
if (remainingBatches > 0 && remainingBatches < eagerLoadBatches) { if (remainingBatches > 0 && remainingBatches < eagerLoadBatches) {
// Continue eager loading if we have more batches within the eager threshold
renderEagerBatch(config); renderEagerBatch(config);
} }
}); });
+1 -1
View File
@@ -9,7 +9,7 @@ interface WasmProviderConfig {
const STORAGE_KEY = 'bentopdf:wasm-providers'; const STORAGE_KEY = 'bentopdf:wasm-providers';
const CDN_DEFAULTS: Record<WasmPackage, string> = { const CDN_DEFAULTS: Record<WasmPackage, string> = {
pymupdf: 'https://cdn.jsdelivr.net/npm/@bentopdf/[email protected]4/', pymupdf: 'https://cdn.jsdelivr.net/npm/@bentopdf/[email protected]6/',
ghostscript: 'https://cdn.jsdelivr.net/npm/@bentopdf/gs-wasm/assets/', ghostscript: 'https://cdn.jsdelivr.net/npm/@bentopdf/gs-wasm/assets/',
cpdf: 'https://cdn.jsdelivr.net/npm/coherentpdf/dist/', cpdf: 'https://cdn.jsdelivr.net/npm/coherentpdf/dist/',
}; };
+174 -149
View File
@@ -8,7 +8,7 @@ import { LitPlugin, Presets as LitPresets } from '@retejs/lit-plugin';
import type { ClassicScheme, LitArea2D } from '@retejs/lit-plugin'; import type { ClassicScheme, LitArea2D } from '@retejs/lit-plugin';
import { DataflowEngine } from 'rete-engine'; import { DataflowEngine } from 'rete-engine';
import type { DataflowEngineScheme } from 'rete-engine'; import type { DataflowEngineScheme } from 'rete-engine';
import { html } from 'lit'; import { LitElement, html } from 'lit';
import type { BaseWorkflowNode } from './nodes/base-node'; import type { BaseWorkflowNode } from './nodes/base-node';
// @ts-ignore -- Vite ?inline import for injecting into Shadow DOM // @ts-ignore -- Vite ?inline import for injecting into Shadow DOM
import phosphorCSS from '@phosphor-icons/web/regular?inline'; import phosphorCSS from '@phosphor-icons/web/regular?inline';
@@ -49,6 +49,175 @@ function getStatusInfo(status: string, connected: boolean) {
}; };
} }
class WorkflowNodeElement extends LitElement {
static properties = {
data: { attribute: false },
emit: { attribute: false },
};
declare data: BaseWorkflowNode | undefined;
declare emit: ((data: unknown) => void) | undefined;
createRenderRoot(): HTMLElement | ShadowRoot {
return this;
}
render() {
if (!this.data) return html``;
const node = this.data;
const inputs = Object.entries(node.inputs || {});
const outputs = Object.entries(node.outputs || {});
const color = categoryColors[node.category] || '#6b7280';
const emitFn = this.emit;
return html`
<div
style="
position: relative; display: flex; flex-direction: column;
align-items: center; width: 280px;
"
>
${inputs.length > 0
? html`
<div
style="display: flex; justify-content: center; gap: 8px; position: relative; z-index: 1; margin-bottom: -7px;"
>
${inputs.map(([key, input]) =>
input
? html`
<div
style="display: flex; align-items: center; justify-content: center;"
>
<rete-ref
.data=${{
type: 'socket',
side: 'input',
key,
nodeId: node.id,
payload: input.socket,
}}
.emit=${emitFn}
></rete-ref>
</div>
`
: null
)}
</div>
`
: null}
<div
style="
background: #1f2937; border: 1px solid #374151;
border-radius: 12px; width: 100%; overflow: hidden;
"
>
<div
style="height: 3px; border-radius: 10px 10px 0 0; overflow: hidden;"
>
<div
data-wf="bar"
style="
height: 100%; width: 100%;
background: #6b7280; opacity: 0.25;
"
></div>
</div>
<div
style="padding: 6px 14px; display: flex; align-items: center; gap: 6px;"
>
<span
data-wf="dot"
style="
width: 7px; height: 7px; border-radius: 50%; background: #6b7280; flex-shrink: 0;
"
></span>
<span
data-wf="label"
style="font-size: 10px; color: #6b7280; font-weight: 500; flex: 1;"
>Not connected</span
>
<span
data-wf-delete="${node.id}"
style="
cursor: pointer; display: flex; align-items: center; justify-content: center;
width: 18px; height: 18px; border-radius: 4px;
color: #6b7280; transition: all 0.15s;
"
>
<svg
width="12"
height="12"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<line x1="18" y1="6" x2="6" y2="18"></line>
<line x1="6" y1="6" x2="18" y2="18"></line>
</svg>
</span>
</div>
<div style="height: 1px; background: #374151; margin: 0 14px;"></div>
<div
style="padding: 10px 14px 12px; display: flex; align-items: flex-start; gap: 10px;"
>
<i
class="ph ${node.icon}"
style="font-size: 18px; color: ${color}; flex-shrink: 0; margin-top: 1px; line-height: 1;"
></i>
<div style="flex: 1; min-width: 0;">
<div
style="font-size: 13px; font-weight: 600; color: #f3f4f6; line-height: 1.3;"
>
${node.label}
</div>
<div
style="font-size: 11px; color: #9ca3af; margin-top: 2px; line-height: 1.3;"
>
${node.description}
</div>
</div>
</div>
</div>
${outputs.length > 0
? html`
<div
style="display: flex; justify-content: center; gap: 8px; position: relative; z-index: 1; margin-top: -7px;"
>
${outputs.map(([key, output]) =>
output
? html`
<div
style="display: flex; align-items: center; justify-content: center;"
>
<rete-ref
.data=${{
type: 'socket',
side: 'output',
key,
nodeId: node.id,
payload: output.socket,
}}
.emit=${emitFn}
></rete-ref>
</div>
`
: null
)}
</div>
`
: null}
</div>
`;
}
}
if (!customElements.get('wf-node')) {
customElements.define('wf-node', WorkflowNodeElement);
}
export function updateNodeDisplay( export function updateNodeDisplay(
nodeId: string, nodeId: string,
editor: NodeEditor<ClassicScheme>, editor: NodeEditor<ClassicScheme>,
@@ -111,154 +280,10 @@ export async function createWorkflowEditor(
customize: { customize: {
node(data) { node(data) {
return ({ emit }: { emit: (data: unknown) => void }) => { return ({ emit }: { emit: (data: unknown) => void }) => {
const node = data.payload as BaseWorkflowNode; return html`<wf-node
const inputs = Object.entries(node.inputs || {}); .data=${data.payload}
const outputs = Object.entries(node.outputs || {}); .emit=${emit}
const color = categoryColors[node.category] || '#6b7280'; ></wf-node>`;
return html`
<div
style="
position: relative; display: flex; flex-direction: column;
align-items: center; width: 280px;
"
>
${inputs.length > 0
? html`
<div
style="display: flex; justify-content: center; gap: 8px; position: relative; z-index: 1; margin-bottom: -7px;"
>
${inputs.map(([key, input]) =>
input
? html`
<div
style="display: flex; align-items: center; justify-content: center;"
>
<rete-ref
.data=${{
type: 'socket',
side: 'input',
key,
nodeId: node.id,
payload: input.socket,
}}
.emit=${emit}
></rete-ref>
</div>
`
: null
)}
</div>
`
: null}
<div
style="
background: #1f2937; border: 1px solid #374151;
border-radius: 12px; width: 100%; overflow: hidden;
"
>
<div
style="height: 3px; border-radius: 10px 10px 0 0; overflow: hidden;"
>
<div
data-wf="bar"
style="
height: 100%; width: 100%;
background: #6b7280; opacity: 0.25;
"
></div>
</div>
<div
style="padding: 6px 14px; display: flex; align-items: center; gap: 6px;"
>
<span
data-wf="dot"
style="
width: 7px; height: 7px; border-radius: 50%; background: #6b7280; flex-shrink: 0;
"
></span>
<span
data-wf="label"
style="font-size: 10px; color: #6b7280; font-weight: 500; flex: 1;"
>Not connected</span
>
<span
data-wf-delete="${node.id}"
style="
cursor: pointer; display: flex; align-items: center; justify-content: center;
width: 18px; height: 18px; border-radius: 4px;
color: #6b7280; transition: all 0.15s;
"
>
<svg
width="12"
height="12"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<line x1="18" y1="6" x2="6" y2="18"></line>
<line x1="6" y1="6" x2="18" y2="18"></line>
</svg>
</span>
</div>
<div
style="height: 1px; background: #374151; margin: 0 14px;"
></div>
<div
style="padding: 10px 14px 12px; display: flex; align-items: flex-start; gap: 10px;"
>
<i
class="ph ${node.icon}"
style="font-size: 18px; color: ${color}; flex-shrink: 0; margin-top: 1px; line-height: 1;"
></i>
<div style="flex: 1; min-width: 0;">
<div
style="font-size: 13px; font-weight: 600; color: #f3f4f6; line-height: 1.3;"
>
${node.label}
</div>
<div
style="font-size: 11px; color: #9ca3af; margin-top: 2px; line-height: 1.3;"
>
${node.description}
</div>
</div>
</div>
</div>
${outputs.length > 0
? html`
<div
style="display: flex; justify-content: center; gap: 8px; position: relative; z-index: 1; margin-top: -7px;"
>
${outputs.map(([key, output]) =>
output
? html`
<div
style="display: flex; align-items: center; justify-content: center;"
>
<rete-ref
.data=${{
type: 'socket',
side: 'output',
key,
nodeId: node.id,
payload: output.socket,
}}
.emit=${emit}
></rete-ref>
</div>
`
: null
)}
</div>
`
: null}
</div>
`;
}; };
}, },
socket() { socket() {
+1
View File
@@ -9,6 +9,7 @@ export abstract class BaseWorkflowNode extends ClassicPreset.Node {
width = 280; width = 280;
height = 140; height = 140;
execStatus: 'idle' | 'running' | 'completed' | 'error' = 'idle'; execStatus: 'idle' | 'running' | 'completed' | 'error' = 'idle';
nodeType: string = '';
constructor(label: string) { constructor(label: string) {
super(label); super(label);
+32 -11
View File
@@ -4,6 +4,7 @@ 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 { PDFDocument } from 'pdf-lib'; import { PDFDocument } from 'pdf-lib';
import { parsePageRange } from '../../utils/pdf-operations';
export class DividePagesNode extends BaseWorkflowNode { export class DividePagesNode extends BaseWorkflowNode {
readonly category = 'Organize & Manage' as const; readonly category = 'Organize & Manage' as const;
@@ -18,6 +19,10 @@ export class DividePagesNode extends BaseWorkflowNode {
'direction', 'direction',
new ClassicPreset.InputControl('text', { initial: 'vertical' }) new ClassicPreset.InputControl('text', { initial: 'vertical' })
); );
this.addControl(
'pages',
new ClassicPreset.InputControl('text', { initial: '' })
);
} }
async data( async data(
@@ -30,23 +35,39 @@ export class DividePagesNode extends BaseWorkflowNode {
const direction = const direction =
dirCtrl?.value === 'horizontal' ? 'horizontal' : 'vertical'; dirCtrl?.value === 'horizontal' ? 'horizontal' : 'vertical';
const pagesCtrl = this.controls['pages'] as
| ClassicPreset.InputControl<'text'>
| undefined;
const rangeStr = (pagesCtrl?.value || '').trim();
return { return {
pdf: await processBatch(pdfInputs, async (input) => { pdf: await processBatch(pdfInputs, async (input) => {
const srcDoc = await PDFDocument.load(input.bytes); const srcDoc = await PDFDocument.load(input.bytes);
const newDoc = await PDFDocument.create(); const newDoc = await PDFDocument.create();
for (let i = 0; i < srcDoc.getPageCount(); i++) { const totalPages = srcDoc.getPageCount();
const [page1] = await newDoc.copyPages(srcDoc, [i]);
const [page2] = await newDoc.copyPages(srcDoc, [i]); const pagesToDivide: Set<number> = rangeStr
const { width, height } = page1.getSize(); ? new Set(parsePageRange(rangeStr, totalPages))
if (direction === 'vertical') { : new Set(Array.from({ length: totalPages }, (_, i) => i));
page1.setCropBox(0, 0, width / 2, height);
page2.setCropBox(width / 2, 0, width / 2, height); for (let i = 0; i < totalPages; i++) {
if (pagesToDivide.has(i)) {
const [page1] = await newDoc.copyPages(srcDoc, [i]);
const [page2] = await newDoc.copyPages(srcDoc, [i]);
const { width, height } = page1.getSize();
if (direction === 'vertical') {
page1.setCropBox(0, 0, width / 2, height);
page2.setCropBox(width / 2, 0, width / 2, height);
} else {
page1.setCropBox(0, height / 2, width, height / 2);
page2.setCropBox(0, 0, width, height / 2);
}
newDoc.addPage(page1);
newDoc.addPage(page2);
} else { } else {
page1.setCropBox(0, height / 2, width, height / 2); const [copiedPage] = await newDoc.copyPages(srcDoc, [i]);
page2.setCropBox(0, 0, width, height / 2); newDoc.addPage(copiedPage);
} }
newDoc.addPage(page1);
newDoc.addPage(page2);
} }
const pdfBytes = await newDoc.save(); const pdfBytes = await newDoc.save();
return { return {
+3 -1
View File
@@ -593,7 +593,9 @@ export const nodeRegistry: Record<string, NodeRegistryEntry> = {
export function createNodeByType(type: string): BaseWorkflowNode | null { export function createNodeByType(type: string): BaseWorkflowNode | null {
const entry = nodeRegistry[type]; const entry = nodeRegistry[type];
if (!entry) return null; if (!entry) return null;
return entry.factory(); const node = entry.factory();
node.nodeType = type;
return node;
} }
export function getNodesByCategory(): Record< export function getNodesByCategory(): Record<
+88 -2
View File
@@ -3,9 +3,10 @@ import { BaseWorkflowNode } from './base-node';
import { pdfSocket } from '../sockets'; import { pdfSocket } from '../sockets';
import type { SocketData } from '../types'; import type { SocketData } from '../types';
import { requirePdfInput, processBatch } from '../types'; import { requirePdfInput, processBatch } from '../types';
import { addTextWatermark } from '../../utils/pdf-operations'; import { addTextWatermark, parsePageRange } from '../../utils/pdf-operations';
import { PDFDocument } from 'pdf-lib'; import { PDFDocument } from 'pdf-lib';
import { hexToRgb } from '../../utils/helpers.js'; import { hexToRgb } from '../../utils/helpers.js';
import * as pdfjsLib from 'pdfjs-dist';
export class WatermarkNode extends BaseWorkflowNode { export class WatermarkNode extends BaseWorkflowNode {
readonly category = 'Edit & Annotate' as const; readonly category = 'Edit & Annotate' as const;
@@ -39,6 +40,18 @@ export class WatermarkNode extends BaseWorkflowNode {
'angle', 'angle',
new ClassicPreset.InputControl('number', { initial: -45 }) new ClassicPreset.InputControl('number', { initial: -45 })
); );
this.addControl(
'position',
new ClassicPreset.InputControl('text', { initial: 'center' })
);
this.addControl(
'pages',
new ClassicPreset.InputControl('text', { initial: 'all' })
);
this.addControl(
'flatten',
new ClassicPreset.InputControl('text', { initial: 'no' })
);
} }
async data( async data(
@@ -67,16 +80,89 @@ export class WatermarkNode extends BaseWorkflowNode {
const opacity = getNum('opacity', 30) / 100; const opacity = getNum('opacity', 30) / 100;
const angle = getNum('angle', -45); const angle = getNum('angle', -45);
const positionPresets: Record<string, { x: number; y: number }> = {
'top-left': { x: 0.15, y: 0.15 },
top: { x: 0.5, y: 0.15 },
'top-right': { x: 0.85, y: 0.15 },
left: { x: 0.15, y: 0.5 },
center: { x: 0.5, y: 0.5 },
right: { x: 0.85, y: 0.5 },
'bottom-left': { x: 0.15, y: 0.85 },
bottom: { x: 0.5, y: 0.85 },
'bottom-right': { x: 0.85, y: 0.85 },
};
const posKey = getText('position', 'center').trim().toLowerCase();
const { x, y } = positionPresets[posKey] ?? positionPresets['center'];
const pagesStr = getText('pages', 'all').trim().toLowerCase();
const shouldFlatten =
getText('flatten', 'no').trim().toLowerCase() === 'yes';
return { return {
pdf: await processBatch(pdfInputs, async (input) => { pdf: await processBatch(pdfInputs, async (input) => {
const resultBytes = await addTextWatermark(input.bytes, { const srcDoc = await PDFDocument.load(input.bytes);
const totalPages = srcDoc.getPageCount();
const pageIndices =
pagesStr === 'all' ? undefined : parsePageRange(pagesStr, totalPages);
let resultBytes = await addTextWatermark(input.bytes, {
text: watermarkText, text: watermarkText,
fontSize, fontSize,
color: { r: c.r, g: c.g, b: c.b }, color: { r: c.r, g: c.g, b: c.b },
opacity, opacity,
angle, angle,
x,
y: 1 - y,
pageIndices,
}); });
if (shouldFlatten) {
const watermarkedPdf = await pdfjsLib.getDocument({
data: resultBytes.slice(),
}).promise;
const flattenedDoc = await PDFDocument.create();
const renderScale = 2.5;
for (let i = 1; i <= watermarkedPdf.numPages; i++) {
const page = await watermarkedPdf.getPage(i);
const unscaledVP = page.getViewport({ scale: 1 });
const viewport = page.getViewport({ scale: renderScale });
const canvas = document.createElement('canvas');
canvas.width = viewport.width;
canvas.height = viewport.height;
const ctx = canvas.getContext('2d')!;
await page.render({ canvasContext: ctx, canvas, viewport }).promise;
const jpegBytes = await new Promise<ArrayBuffer>(
(resolve, reject) =>
canvas.toBlob(
(blob) =>
blob
? blob.arrayBuffer().then(resolve)
: reject(new Error(`Failed to rasterize page ${i}`)),
'image/jpeg',
0.92
)
);
const image = await flattenedDoc.embedJpg(jpegBytes);
const newPage = flattenedDoc.addPage([
unscaledVP.width,
unscaledVP.height,
]);
newPage.drawImage(image, {
x: 0,
y: 0,
width: unscaledVP.width,
height: unscaledVP.height,
});
}
resultBytes = new Uint8Array(await flattenedDoc.save());
}
const resultDoc = await PDFDocument.load(resultBytes); const resultDoc = await PDFDocument.load(resultBytes);
return { return {
+35 -14
View File
@@ -25,8 +25,7 @@ interface SerializedConnection {
} }
function getNodeType(node: BaseWorkflowNode): string | null { function getNodeType(node: BaseWorkflowNode): string | null {
const constructorName = node.constructor.name; return node.nodeType || null;
return constructorName || null;
} }
function serializeWorkflow( function serializeWorkflow(
@@ -79,6 +78,22 @@ async function deserializeWorkflow(
editor: NodeEditor<ClassicScheme>, editor: NodeEditor<ClassicScheme>,
area: AreaPlugin<ClassicScheme, AreaExtra> area: AreaPlugin<ClassicScheme, AreaExtra>
): Promise<void> { ): Promise<void> {
if (
!data ||
!Array.isArray((data as any).nodes) ||
!Array.isArray((data as any).connections)
) {
throw new Error(
'Invalid workflow file: missing nodes or connections array.'
);
}
if ((data as any).version !== WORKFLOW_VERSION) {
console.warn(
`Workflow version mismatch: expected ${WORKFLOW_VERSION}, got ${(data as any).version}. Attempting load anyway.`
);
}
for (const conn of editor.getConnections()) { for (const conn of editor.getConnections()) {
await editor.removeConnection(conn.id); await editor.removeConnection(conn.id);
} }
@@ -129,9 +144,6 @@ async function deserializeWorkflow(
if (skippedTypes.length > 0) { if (skippedTypes.length > 0) {
console.warn('Skipped unknown node types during load:', skippedTypes); console.warn('Skipped unknown node types during load:', skippedTypes);
throw new Error(
`Some nodes could not be loaded: ${skippedTypes.join(', ')}. They may have been removed or renamed.`
);
} }
} }
@@ -215,14 +227,11 @@ export function exportWorkflow(
const json = JSON.stringify(data, null, 2); const json = JSON.stringify(data, null, 2);
const blob = new Blob([json], { type: 'application/json' }); const blob = new Blob([json], { type: 'application/json' });
const url = URL.createObjectURL(blob); const url = URL.createObjectURL(blob);
try { const a = document.createElement('a');
const a = document.createElement('a'); a.href = url;
a.href = url; a.download = 'workflow.json';
a.download = 'workflow.json'; a.click();
a.click(); setTimeout(() => URL.revokeObjectURL(url), 1000);
} finally {
URL.revokeObjectURL(url);
}
} }
export async function importWorkflow( export async function importWorkflow(
@@ -233,13 +242,16 @@ export async function importWorkflow(
const input = document.createElement('input'); const input = document.createElement('input');
input.type = 'file'; input.type = 'file';
input.accept = '.json'; input.accept = '.json';
let settled = false;
input.onchange = async () => { input.onchange = async () => {
settled = true;
const file = input.files?.[0]; const file = input.files?.[0];
if (!file) { if (!file) {
resolve(); resolve();
return; return;
} }
try { try {
const text = await file.text(); const text = await file.text();
const data = JSON.parse(text) as SerializedWorkflow; const data = JSON.parse(text) as SerializedWorkflow;
@@ -250,6 +262,15 @@ export async function importWorkflow(
reject(new Error(`Failed to import workflow: ${message}`)); reject(new Error(`Failed to import workflow: ${message}`));
} }
}; };
const onFocus = () => {
window.removeEventListener('focus', onFocus);
setTimeout(() => {
if (!settled) resolve();
}, 300);
};
window.addEventListener('focus', onFocus);
input.click(); input.click();
}); });
} }
+378 -143
View File
@@ -158,171 +158,406 @@
</div> </div>
<div id="file-display-area" class="mt-4 space-y-2"></div> <div id="file-display-area" class="mt-4 space-y-2"></div>
</div>
</div>
<div id="options-panel" class="hidden mt-6 space-y-4"> <div id="editor-panel" class="hidden bg-gray-900 px-2 sm:px-4 py-4 sm:py-8">
<div class="flex gap-4 mb-4"> <div class="max-w-7xl mx-auto">
<label class="flex items-center gap-2 cursor-pointer"> <div
<input class="flex flex-col sm:flex-row sm:items-center gap-1 sm:gap-0 sm:justify-between mb-4 sm:mb-6"
type="radio" >
name="watermark-type" <button
value="text" id="editor-back-btn"
checked class="flex items-center gap-2 text-indigo-400 hover:text-indigo-300 font-semibold text-sm"
class="w-4 h-4 text-indigo-600 bg-gray-700 border-gray-600 focus:ring-indigo-500" >
/> <i data-lucide="arrow-left" class="w-4 h-4"></i>
<span class="text-sm font-medium text-gray-300" <span>Change File</span>
>Text Watermark</span </button>
<div
id="editor-file-info"
class="text-xs sm:text-sm text-gray-400 truncate max-w-full sm:max-w-[60%]"
></div>
</div>
<div class="flex flex-col lg:flex-row gap-4 sm:gap-6">
<div class="flex-1 min-w-0 flex flex-col">
<div
class="bg-gray-800 rounded-xl border border-gray-700 p-2 sm:p-4 flex-1 flex flex-col"
>
<div class="flex items-center justify-between mb-2 sm:mb-3">
<span class="text-xs sm:text-sm font-medium text-gray-300"
>Preview</span
>
<div class="flex items-center gap-2">
<button
id="prev-page-btn"
disabled
class="p-1 rounded hover:bg-gray-700 text-gray-400 disabled:opacity-30 disabled:cursor-not-allowed transition-colors"
>
<i data-lucide="chevron-left" class="w-4 h-4"></i>
</button>
<div class="flex items-center gap-1 text-xs text-gray-300">
<input
id="page-num-input"
type="number"
min="1"
value="1"
class="w-10 bg-gray-700 border border-gray-600 text-white rounded text-center text-xs py-0.5"
/>
<span>/ <span id="total-pages">1</span></span>
</div>
<button
id="next-page-btn"
disabled
class="p-1 rounded hover:bg-gray-700 text-gray-400 disabled:opacity-30 disabled:cursor-not-allowed transition-colors"
>
<i data-lucide="chevron-right" class="w-4 h-4"></i>
</button>
</div>
<span class="text-xs text-gray-500 hidden sm:inline"
>Drag watermark to position</span
>
</div>
<div
id="preview-wrapper"
class="flex-1 min-h-[200px] lg:min-h-0 flex items-center justify-center bg-gray-900 rounded-lg overflow-hidden"
> >
</label> <div
<label class="flex items-center gap-2 cursor-pointer"> id="preview-container"
<input class="relative overflow-hidden select-none"
type="radio" style="touch-action: none"
name="watermark-type" >
value="image" <canvas id="preview-canvas"></canvas>
class="w-4 h-4 text-indigo-600 bg-gray-700 border-gray-600 focus:ring-indigo-500" <div
/> id="watermark-box"
<span class="text-sm font-medium text-gray-300" class="absolute pointer-events-auto"
>Image Watermark</span style="
> left: 50%;
</label> top: 50%;
transform: translate(-50%, -50%);
"
>
<div
class="relative border border-dashed border-indigo-400/60 cursor-grab active:cursor-grabbing"
>
<div id="watermark-overlay" class="whitespace-nowrap">
CONFIDENTIAL
</div>
<img id="image-watermark-overlay" class="hidden" alt="" />
<div
data-handle="nw"
class="resize-handle absolute -top-1.5 -left-1.5 w-3 h-3 bg-white border-2 border-indigo-500 rounded-sm cursor-nw-resize pointer-events-auto z-10"
></div>
<div
data-handle="ne"
class="resize-handle absolute -top-1.5 -right-1.5 w-3 h-3 bg-white border-2 border-indigo-500 rounded-sm cursor-ne-resize pointer-events-auto z-10"
></div>
<div
data-handle="sw"
class="resize-handle absolute -bottom-1.5 -left-1.5 w-3 h-3 bg-white border-2 border-indigo-500 rounded-sm cursor-sw-resize pointer-events-auto z-10"
></div>
<div
data-handle="se"
class="resize-handle absolute -bottom-1.5 -right-1.5 w-3 h-3 bg-white border-2 border-indigo-500 rounded-sm cursor-se-resize pointer-events-auto z-10"
></div>
</div>
</div>
</div>
</div>
</div>
</div> </div>
<div id="text-watermark-options"> <div class="w-full lg:w-80 flex-shrink-0">
<div class="space-y-4"> <div
<div> class="bg-gray-800 rounded-xl border border-gray-700 p-3 sm:p-5 space-y-3 sm:space-y-5"
>
<div class="flex gap-2">
<button
id="type-text-btn"
class="flex-1 py-2 px-3 text-sm font-medium rounded-lg bg-indigo-600 text-white transition-colors"
>
Text
</button>
<button
id="type-image-btn"
class="flex-1 py-2 px-3 text-sm font-medium rounded-lg bg-gray-700 text-gray-300 hover:bg-gray-600 transition-colors"
>
Image
</button>
</div>
<div
class="flex items-center gap-2 bg-gray-900 rounded-lg px-3 py-2"
>
<input
type="checkbox"
id="apply-all-pages"
checked
class="w-4 h-4 accent-indigo-500 bg-gray-700 border-gray-600 rounded focus:ring-indigo-500"
/>
<label <label
for="watermark-text" for="apply-all-pages"
class="block mb-2 text-sm font-medium text-gray-300" class="text-sm text-gray-300 cursor-pointer"
>Watermark Text</label data-i18n="tools:addWatermark.applyToAllPages"
>Apply to all pages</label
>
</div>
<div id="page-range-section" class="space-y-1.5">
<label
for="page-range-input"
class="block text-sm font-medium text-gray-300"
>Page Range</label
> >
<input <input
type="text" type="text"
id="watermark-text" id="page-range-input"
placeholder="CONFIDENTIAL" value="all"
class="w-full bg-gray-700 border border-gray-600 text-white rounded-lg p-2.5" placeholder="all or 1-3, 5, 7-9"
class="w-full bg-gray-700 border border-gray-600 text-white rounded-lg p-2.5 text-sm placeholder-gray-500"
/> />
<p class="text-xs text-gray-500">
Use "all" or specify pages, e.g. 1-3, 5, 7-9
</p>
</div>
<div id="text-watermark-options">
<div class="space-y-4">
<div>
<label
for="watermark-text"
class="block mb-1.5 text-sm font-medium text-gray-300"
>Text</label
>
<input
type="text"
id="watermark-text"
placeholder="CONFIDENTIAL"
class="w-full bg-gray-700 border border-gray-600 text-white rounded-lg p-2.5 text-sm"
/>
</div>
<div class="grid grid-cols-2 gap-3">
<div>
<label
for="font-size"
class="block mb-1.5 text-sm font-medium text-gray-300"
>Font Size</label
>
<input
type="number"
id="font-size"
value="72"
min="10"
max="200"
class="w-full bg-gray-700 border border-gray-600 text-white rounded-lg p-2.5 text-sm"
/>
</div>
<div>
<label
for="text-color"
class="block mb-1.5 text-sm font-medium text-gray-300"
>Color</label
>
<input
type="color"
id="text-color"
value="#888888"
class=""
/>
</div>
</div>
<div>
<label
for="opacity-text"
class="block mb-1.5 text-sm font-medium text-gray-300"
>
Opacity: <span id="opacity-value-text">0.3</span>
</label>
<input
type="range"
id="opacity-text"
min="0.05"
max="1"
step="0.05"
value="0.3"
class="w-full h-2 bg-gray-700 rounded-lg cursor-pointer accent-indigo-500"
/>
</div>
<div>
<label
for="angle-text"
class="block mb-1.5 text-sm font-medium text-gray-300"
>
Angle: <span id="angle-value-text">-45</span>&deg;
</label>
<input
type="range"
id="angle-text"
min="-90"
max="90"
step="1"
value="-45"
class="w-full h-2 bg-gray-700 rounded-lg cursor-pointer accent-indigo-500"
/>
</div>
</div>
</div>
<div id="image-watermark-options" class="hidden">
<div class="space-y-4">
<div>
<label
for="image-watermark-input"
class="block mb-1.5 text-sm font-medium text-gray-300"
>Watermark Image</label
>
<input
type="file"
id="image-watermark-input"
accept="image/png, image/jpeg"
class="w-full bg-gray-700 border border-gray-600 text-white rounded-lg p-2 text-sm file:mr-3 file:py-1.5 file:px-3 file:rounded-md file:border-0 file:text-xs file:font-semibold file:bg-indigo-600 file:text-white hover:file:bg-indigo-700"
/>
</div>
<div>
<label
for="image-scale"
class="block mb-1.5 text-sm font-medium text-gray-300"
>
Scale: <span id="image-scale-value">100</span>%
</label>
<input
type="range"
id="image-scale"
min="10"
max="200"
step="5"
value="100"
class="w-full h-2 bg-gray-700 rounded-lg cursor-pointer accent-indigo-500"
/>
</div>
<div>
<label
for="opacity-image"
class="block mb-1.5 text-sm font-medium text-gray-300"
>
Opacity: <span id="opacity-value-image">0.3</span>
</label>
<input
type="range"
id="opacity-image"
min="0.05"
max="1"
step="0.05"
value="0.3"
class="w-full h-2 bg-gray-700 rounded-lg cursor-pointer accent-indigo-500"
/>
</div>
<div>
<label
for="angle-image"
class="block mb-1.5 text-sm font-medium text-gray-300"
>
Angle: <span id="angle-value-image">0</span>&deg;
</label>
<input
type="range"
id="angle-image"
min="-90"
max="90"
step="1"
value="0"
class="w-full h-2 bg-gray-700 rounded-lg cursor-pointer accent-indigo-500"
/>
</div>
</div>
</div> </div>
<div class="grid grid-cols-2 gap-4">
<div>
<label
for="font-size"
class="block mb-2 text-sm font-medium text-gray-300"
>Font Size</label
>
<input
type="number"
id="font-size"
value="72"
min="10"
max="200"
class="w-full bg-gray-700 border border-gray-600 text-white rounded-lg p-2.5"
/>
</div>
<div>
<label
for="text-color"
class="block mb-2 text-sm font-medium text-gray-300"
>Color</label
>
<input
type="color"
id="text-color"
value="#888888"
class="w-full h-10 bg-gray-700 border border-gray-600 rounded-lg cursor-pointer"
/>
</div>
</div>
<div class="grid grid-cols-2 gap-4">
<div>
<label
for="opacity-text"
class="block mb-2 text-sm font-medium text-gray-300"
>Opacity: <span id="opacity-value-text">0.3</span></label
>
<input
type="range"
id="opacity-text"
min="0.1"
max="1"
step="0.1"
value="0.3"
class="w-full h-2 bg-gray-700 rounded-lg cursor-pointer"
/>
</div>
<div>
<label
for="angle-text"
class="block mb-2 text-sm font-medium text-gray-300"
>Angle: <span id="angle-value-text">-45</span>°</label
>
<input
type="range"
id="angle-text"
min="-90"
max="90"
step="5"
value="-45"
class="w-full h-2 bg-gray-700 rounded-lg cursor-pointer"
/>
</div>
</div>
</div>
</div>
<div id="image-watermark-options" class="hidden">
<div class="space-y-4">
<div> <div>
<label <label class="block mb-1.5 text-sm font-medium text-gray-300"
for="image-watermark-input" >Position</label
class="block mb-2 text-sm font-medium text-gray-300"
>Watermark Image (PNG/JPG)</label
> >
<div class="grid grid-cols-3 gap-1.5">
<button
data-pos="0.15,0.15"
class="pos-preset-btn py-1.5 text-xs bg-gray-700 hover:bg-gray-600 text-gray-300 rounded-md transition-colors"
>
Top Left
</button>
<button
data-pos="0.5,0.15"
class="pos-preset-btn py-1.5 text-xs bg-gray-700 hover:bg-gray-600 text-gray-300 rounded-md transition-colors"
>
Top
</button>
<button
data-pos="0.85,0.15"
class="pos-preset-btn py-1.5 text-xs bg-gray-700 hover:bg-gray-600 text-gray-300 rounded-md transition-colors"
>
Top Right
</button>
<button
data-pos="0.15,0.5"
class="pos-preset-btn py-1.5 text-xs bg-gray-700 hover:bg-gray-600 text-gray-300 rounded-md transition-colors"
>
Left
</button>
<button
data-pos="0.5,0.5"
class="pos-preset-btn py-1.5 text-xs bg-indigo-600 hover:bg-indigo-500 text-white rounded-md transition-colors"
>
Center
</button>
<button
data-pos="0.85,0.5"
class="pos-preset-btn py-1.5 text-xs bg-gray-700 hover:bg-gray-600 text-gray-300 rounded-md transition-colors"
>
Right
</button>
<button
data-pos="0.15,0.85"
class="pos-preset-btn py-1.5 text-xs bg-gray-700 hover:bg-gray-600 text-gray-300 rounded-md transition-colors"
>
Bottom Left
</button>
<button
data-pos="0.5,0.85"
class="pos-preset-btn py-1.5 text-xs bg-gray-700 hover:bg-gray-600 text-gray-300 rounded-md transition-colors"
>
Bottom
</button>
<button
data-pos="0.85,0.85"
class="pos-preset-btn py-1.5 text-xs bg-gray-700 hover:bg-gray-600 text-gray-300 rounded-md transition-colors"
>
Bottom Right
</button>
</div>
</div>
<div class="flex items-start gap-3 bg-gray-900 rounded-lg p-3">
<input <input
type="file" type="checkbox"
id="image-watermark-input" id="flatten-watermark"
accept="image/png, image/jpeg" class="mt-0.5 w-4 h-4 text-indigo-600 bg-gray-700 border-gray-600 rounded focus:ring-indigo-500"
class="w-full bg-gray-700 border border-gray-600 text-white rounded-lg p-2.5 file:mr-4 file:py-2 file:px-4 file:rounded-lg file:border-0 file:text-sm file:font-semibold file:bg-indigo-600 file:text-white hover:file:bg-indigo-700"
/> />
</div>
<div class="grid grid-cols-2 gap-4">
<div> <div>
<label <label
for="opacity-image" for="flatten-watermark"
class="block mb-2 text-sm font-medium text-gray-300" class="text-sm font-medium text-gray-300 cursor-pointer"
>Opacity: <span id="opacity-value-image">0.3</span></label >Flatten watermark</label
> >
<input <p class="text-xs text-gray-500 mt-0.5">
type="range" Bakes the watermark into page pixels, making it
id="opacity-image" tamper-resistant. Text will no longer be selectable.
min="0.1" </p>
max="1"
step="0.1"
value="0.3"
class="w-full h-2 bg-gray-700 rounded-lg cursor-pointer"
/>
</div>
<div>
<label
for="angle-image"
class="block mb-2 text-sm font-medium text-gray-300"
>Angle: <span id="angle-value-image">0</span>°</label
>
<input
type="range"
id="angle-image"
min="-90"
max="90"
step="5"
value="0"
class="w-full h-2 bg-gray-700 rounded-lg cursor-pointer"
/>
</div> </div>
</div> </div>
<button id="process-btn" class="btn-gradient w-full">
Add Watermark
</button>
</div> </div>
</div> </div>
<button id="process-btn" class="btn-gradient w-full mt-4">
Add Watermark
</button>
</div> </div>
</div> </div>
</div> </div>
+1 -6
View File
@@ -182,12 +182,7 @@
class="block mb-2 text-sm font-medium text-gray-300" class="block mb-2 text-sm font-medium text-gray-300"
>Background Color</label >Background Color</label
> >
<input <input type="color" id="background-color" value="#FFFFCC" />
type="color"
id="background-color"
value="#FFFFCC"
class="w-full h-12 bg-gray-700 border border-gray-600 rounded-lg cursor-pointer"
/>
</div> </div>
<button id="process-btn" class="btn-gradient w-full mt-4"> <button id="process-btn" class="btn-gradient w-full mt-4">
Change Background Color Change Background Color
+543
View File
@@ -0,0 +1,543 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Bates Numbering Online Free - Add Bates Stamps | BentoPDF</title>
<meta
name="description"
content="Add sequential Bates numbers to PDF documents. Supports multi-file processing, custom templates, and configurable formatting. Free, secure, browser-based."
/>
<meta
name="keywords"
content="bates numbering, bates stamp, pdf bates, legal pdf numbering"
/>
<meta name="author" content="BentoPDF" />
<meta name="robots" content="index, follow" />
<link
rel="canonical"
href="https://www.bentopdf.com/bates-numbering.html"
/>
<meta property="og:type" content="website" />
<meta
property="og:url"
content="https://www.bentopdf.com/bates-numbering"
/>
<meta
property="og:title"
content="Bates Numbering Online Free | BentoPDF"
/>
<meta
property="og:description"
content="Add sequential Bates numbers to PDF documents. Multi-file support, custom templates. Free and private."
/>
<meta property="og:site_name" content="BentoPDF" />
<meta name="twitter:card" content="summary_large_image" />
<meta name="twitter:title" content="Bates Numbering Free" />
<meta name="twitter:site" content="@BentoPDF" />
<link href="/src/css/styles.css" rel="stylesheet" />
<link rel="manifest" href="/site.webmanifest" />
<link rel="icon" type="image/svg+xml" href="/images/favicon.svg" />
<link
rel="icon"
type="image/png"
sizes="192x192"
href="/images/favicon-192x192.png"
/>
<link
rel="icon"
type="image/png"
sizes="512x512"
href="/images/favicon-512x512.png"
/>
<link
rel="apple-touch-icon"
sizes="180x180"
href="/images/apple-touch-icon.png"
/>
<link rel="icon" href="/favicon.ico" sizes="32x32" />
</head>
<body class="antialiased bg-gray-900">
{{> navbar }}
<div
id="uploader"
class="min-h-screen flex flex-col items-center justify-start py-12 p-4 bg-gray-900"
>
<div
id="tool-uploader"
class="bg-gray-800 rounded-xl shadow-xl px-4 py-8 md:p-8 max-w-2xl w-full text-gray-200 border border-gray-700"
>
<button
id="back-to-tools"
class="flex items-center gap-2 text-indigo-400 hover:text-indigo-300 mb-6 font-semibold"
>
<i data-lucide="arrow-left" class="cursor-pointer"></i>
<span class="cursor-pointer" data-i18n="tools.backToTools"
>Back to Tools</span
>
</button>
<h1
class="text-2xl font-bold text-white mb-2"
data-i18n="tools:batesNumbering.name"
>
Bates Numbering
</h1>
<p class="text-gray-400 mb-6" data-i18n="tools:batesNumbering.subtitle">
Add sequential Bates numbers across one or more PDF files.
</p>
<div
id="drop-zone"
class="relative flex flex-col items-center justify-center w-full h-48 md:h-64 border-2 border-dashed border-gray-600 rounded-xl cursor-pointer bg-gray-900 hover:bg-gray-700 transition-colors duration-300"
>
<div class="flex flex-col items-center justify-center pt-5 pb-6">
<i
data-lucide="upload-cloud"
class="w-10 h-10 mb-3 text-gray-400"
></i>
<p class="mb-2 text-sm text-gray-400">
<span class="font-semibold" data-i18n="upload.clickToSelect"
>Click to select files</span
>
<span data-i18n="upload.orDragAndDrop">or drag and drop</span>
</p>
<p class="text-xs text-gray-500">PDF files (multiple supported)</p>
<p class="text-xs text-gray-500" data-i18n="upload.filesNeverLeave">
Your files never leave your device.
</p>
</div>
<input
id="file-input"
type="file"
class="absolute top-0 left-0 w-full h-full opacity-0 cursor-pointer"
multiple
accept="application/pdf"
/>
</div>
<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>Add More Files</span>
</button>
<button
id="clear-files-btn"
class="btn bg-gray-700 hover:bg-gray-600 text-white font-semibold px-4 py-2 rounded-lg flex items-center gap-2"
>
<i data-lucide="trash-2"></i>
<span>Clear All</span>
</button>
</div>
<div id="file-list" class="mt-4 space-y-2"></div>
<div id="options-panel" class="hidden mt-6 space-y-4">
<div>
<label
for="style-preset"
class="block mb-2 text-sm font-medium text-gray-300"
>Select Style</label
>
<select
id="style-preset"
class="w-full bg-gray-700 border border-gray-600 text-white rounded-lg p-2.5 focus:ring-indigo-500 focus:border-indigo-500"
>
<optgroup label="Exhibit + Case + Bates + Page">
<option value="full-6">Exhibit 1 Case XYZ 000001 Page 1</option>
<option value="full-5">Exhibit 1 Case XYZ 00001 Page 1</option>
<option value="full-4">Exhibit 1 Case XYZ 0001 Page 1</option>
<option value="full-3">Exhibit 1 Case XYZ 001 Page 1</option>
<option value="full-0">Exhibit 1 Case XYZ 1 Page 1</option>
</optgroup>
<optgroup label="Exhibit + Case + Bates">
<option value="no-page-6">Exhibit 1 Case XYZ 000001</option>
<option value="no-page-5">Exhibit 1 Case XYZ 00001</option>
<option value="no-page-4">Exhibit 1 Case XYZ 0001</option>
<option value="no-page-3">Exhibit 1 Case XYZ 001</option>
<option value="no-page-0">Exhibit 1 Case XYZ 1</option>
</optgroup>
<optgroup label="Case + Bates">
<option value="case-6">Case XYZ 000001</option>
<option value="case-5">Case XYZ 00001</option>
<option value="case-4">Case XYZ 0001</option>
<option value="case-3">Case XYZ 001</option>
<option value="case-0">Case XYZ 1</option>
</optgroup>
<optgroup label="Bates Number Only">
<option value="bates-6" selected>000001, 000002, 000003</option>
<option value="bates-5">00001, 00002, 00003</option>
<option value="bates-4">0001, 0002, 0003</option>
<option value="bates-3">001, 002, 003</option>
<option value="bates-0">1, 2, 3</option>
</optgroup>
<option value="custom">Custom style (other)</option>
</select>
</div>
<div>
<label
for="bates-template"
class="block mb-2 text-sm font-medium text-gray-300"
>Customize Style</label
>
<input
type="text"
id="bates-template"
value="[BATES]"
class="w-full bg-gray-700 border border-gray-600 text-white rounded-lg p-2.5 focus:ring-indigo-500 focus:border-indigo-500 font-mono"
/>
<p class="text-xs text-gray-500 mt-1">
Placeholders: [BATES] [PAGE] [FILE] [FILENAME]
</p>
</div>
<div
id="preview-box"
class="bg-gray-900 border border-gray-700 rounded-lg p-4 text-sm text-gray-300 font-mono"
>
<div class="text-xs text-gray-500 mb-1">Preview:</div>
<div id="preview-content"></div>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div id="padding-group" class="hidden">
<label
for="bates-padding"
class="block mb-2 text-sm font-medium text-gray-300"
>Bates Padding</label
>
<select
id="bates-padding"
class="w-full bg-gray-700 border border-gray-600 text-white rounded-lg p-2.5 focus:ring-indigo-500 focus:border-indigo-500"
>
<option value="6" selected>6 digits (000001)</option>
<option value="5">5 digits (00001)</option>
<option value="4">4 digits (0001)</option>
<option value="3">3 digits (001)</option>
<option value="0">No padding (1)</option>
</select>
</div>
<div>
<label
for="position"
class="block mb-2 text-sm font-medium text-gray-300"
>Position</label
>
<select
id="position"
class="w-full bg-gray-700 border border-gray-600 text-white rounded-lg p-2.5 focus:ring-indigo-500 focus:border-indigo-500"
>
<option value="top-center">Header Center</option>
<option value="top-left">Header Left</option>
<option value="top-right">Header Right</option>
<option value="bottom-center" selected>Footer Center</option>
<option value="bottom-left">Footer Left</option>
<option value="bottom-right">Footer Right</option>
</select>
</div>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label
for="bates-start"
class="block mb-2 text-sm font-medium text-gray-300"
>Bates Starts From</label
>
<input
type="number"
id="bates-start"
value="1"
min="0"
class="w-full bg-gray-700 border border-gray-600 text-white rounded-lg p-2.5 focus:ring-indigo-500 focus:border-indigo-500"
/>
</div>
<div>
<label
for="file-start"
class="block mb-2 text-sm font-medium text-gray-300"
>File Counter Starts From</label
>
<input
type="number"
id="file-start"
value="1"
min="1"
class="w-full bg-gray-700 border border-gray-600 text-white rounded-lg p-2.5 focus:ring-indigo-500 focus:border-indigo-500"
/>
</div>
</div>
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
<div>
<label
for="font-family"
class="block mb-2 text-sm font-medium text-gray-300"
>Font</label
>
<select
id="font-family"
class="w-full bg-gray-700 border border-gray-600 text-white rounded-lg p-2.5 focus:ring-indigo-500 focus:border-indigo-500"
>
<option value="Helvetica">Helvetica</option>
<option value="TimesRoman">Times New Roman</option>
<option value="Courier">Courier</option>
</select>
</div>
<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="10"
min="6"
max="72"
class="w-full bg-gray-700 border border-gray-600 text-white rounded-lg p-2.5 focus:ring-indigo-500 focus:border-indigo-500"
/>
</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="#000000" />
</div>
</div>
<button id="process-btn" class="btn-gradient w-full mt-4">
Apply Bates Numbers
</button>
</div>
</div>
</div>
<div
id="loader-modal"
class="hidden fixed inset-0 bg-black bg-opacity-75 flex items-center justify-center z-50"
>
<div
class="bg-gray-800 p-8 rounded-lg flex flex-col items-center gap-4 border border-gray-700 shadow-xl"
>
<div class="solid-spinner"></div>
<p
id="loader-text"
class="text-white text-lg font-medium"
data-i18n="loader.processing"
>
Processing...
</p>
</div>
</div>
<div
id="alert-modal"
class="fixed inset-0 bg-gray-900 bg-opacity-90 flex items-center justify-center z-50 hidden"
>
<div
class="bg-gray-800 rounded-lg shadow-xl p-6 max-w-sm w-full border border-gray-700"
>
<h3 id="alert-title" class="text-xl font-bold text-white mb-2">
Alert
</h3>
<p id="alert-message" class="text-gray-300 mb-6"></p>
<button
id="alert-ok"
class="w-full bg-indigo-600 hover:bg-indigo-700 text-white font-semibold py-2 px-4 rounded-lg transition-colors duration-200"
>
OK
</button>
</div>
</div>
<section class="max-w-4xl mx-auto px-4 py-12">
<h2
class="text-2xl md:text-3xl font-bold text-white mb-8 text-center"
data-i18n="howItWorks.title"
>
How It Works
</h2>
<div class="space-y-6">
<div class="flex items-start gap-4">
<div
class="flex-shrink-0 w-10 h-10 bg-indigo-600 rounded-full flex items-center justify-center text-white font-bold"
>
1
</div>
<div class="flex-1">
<h3 class="text-lg font-semibold text-white mb-1">Upload Files</h3>
<p class="text-gray-400">
Upload one or more PDF files. Drag to reorder for multi-file
sequencing.
</p>
</div>
</div>
<div class="flex items-start gap-4">
<div
class="flex-shrink-0 w-10 h-10 bg-indigo-600 rounded-full flex items-center justify-center text-white font-bold"
>
2
</div>
<div class="flex-1">
<h3 class="text-lg font-semibold text-white mb-1">
Configure Style
</h3>
<p class="text-gray-400">
Choose a preset or create a custom template with placeholders like
[BATES], [PAGE], [FILE].
</p>
</div>
</div>
<div class="flex items-start gap-4">
<div
class="flex-shrink-0 w-10 h-10 bg-indigo-600 rounded-full flex items-center justify-center text-white font-bold"
>
3
</div>
<div class="flex-1">
<h3 class="text-lg font-semibold text-white mb-1">Download</h3>
<p class="text-gray-400">
Get your bates-stamped PDFs instantly. Multiple files are
delivered as a ZIP.
</p>
</div>
</div>
</div>
</section>
<section class="max-w-6xl mx-auto px-4 py-12">
<h2
class="text-2xl md:text-3xl font-bold text-white mb-6 text-center"
data-i18n="relatedTools.title"
>
Related PDF Tools
</h2>
<div class="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-6 gap-4">
<a
href="page-numbers.html"
class="block bg-gray-800 p-4 rounded-lg hover:bg-gray-700 transition-colors border border-gray-700"
>
<h3 class="text-white font-semibold mb-1">Page Numbers</h3>
<p class="text-gray-400 text-sm">Add page numbers to PDFs</p>
</a>
<a
href="header-footer.html"
class="block bg-gray-800 p-4 rounded-lg hover:bg-gray-700 transition-colors border border-gray-700"
>
<h3 class="text-white font-semibold mb-1">Header & Footer</h3>
<p class="text-gray-400 text-sm">
Add text to page headers and footers
</p>
</a>
<a
href="add-watermark.html"
class="block bg-gray-800 p-4 rounded-lg hover:bg-gray-700 transition-colors border border-gray-700"
>
<h3 class="text-white font-semibold mb-1">Add Watermark</h3>
<p class="text-gray-400 text-sm">Watermark your PDF documents</p>
</a>
<a
href="merge-pdf.html"
class="block bg-gray-800 p-4 rounded-lg hover:bg-gray-700 transition-colors border border-gray-700"
>
<h3 class="text-white font-semibold mb-1">Merge PDF</h3>
<p class="text-gray-400 text-sm">Combine multiple PDFs</p>
</a>
<a
href="organize-pdf.html"
class="block bg-gray-800 p-4 rounded-lg hover:bg-gray-700 transition-colors border border-gray-700"
>
<h3 class="text-white font-semibold mb-1">Organize PDF</h3>
<p class="text-gray-400 text-sm">Reorder and organize pages</p>
</a>
</div>
</section>
<section class="max-w-4xl mx-auto px-4 py-12">
<h2
class="text-2xl md:text-3xl font-bold text-white mb-6 text-center"
data-i18n="faq.sectionTitle"
>
Frequently Asked Questions
</h2>
<div class="space-y-4">
<details class="bg-gray-800 p-5 rounded-lg border border-gray-700">
<summary
class="cursor-pointer font-semibold text-white flex items-center justify-between"
>
What is Bates numbering?
<i data-lucide="chevron-down" class="w-5 h-5"></i>
</summary>
<p class="mt-3 text-gray-400">
Bates numbering is a method of indexing legal documents for easy
identification and retrieval. Each page receives a unique sequential
number, often combined with a prefix or suffix.
</p>
</details>
<details class="bg-gray-800 p-5 rounded-lg border border-gray-700">
<summary
class="cursor-pointer font-semibold text-white flex items-center justify-between"
>
Can I number multiple files at once?
<i data-lucide="chevron-down" class="w-5 h-5"></i>
</summary>
<p class="mt-3 text-gray-400">
Yes! Upload multiple PDFs and the bates numbers will continue
sequentially across all files. Drag files to reorder them.
</p>
</details>
<details class="bg-gray-800 p-5 rounded-lg border border-gray-700">
<summary
class="cursor-pointer font-semibold text-white flex items-center justify-between"
>
Are my files secure?
<i data-lucide="chevron-down" class="w-5 h-5"></i>
</summary>
<p class="mt-3 text-gray-400">
All processing happens entirely in your browser. Your files never
leave your device.
</p>
</details>
</div>
</section>
{{> footer }}
<script type="module" src="/src/js/utils/lucide-init.ts"></script>
<script type="module" src="/src/js/utils/full-width.ts"></script>
<script type="module" src="/src/js/utils/simple-mode-footer.ts"></script>
<script type="module" src="/src/version.ts"></script>
<script type="module" src="/src/js/logic/bates-numbering-page.ts"></script>
<script type="module" src="/src/js/mobileMenu.ts"></script>
<script type="module" src="/src/js/main.ts"></script>
<script type="application/ld+json">
{
"@context": "https://schema.org",
"@type": "SoftwareApplication",
"name": "Bates Numbering - BentoPDF",
"applicationCategory": "PDF Tool",
"operatingSystem": "Any - Web Browser",
"offers": { "@type": "Offer", "price": "0", "priceCurrency": "USD" }
}
</script>
</body>
</html>
+2 -12
View File
@@ -217,12 +217,7 @@
class="block mb-2 text-sm font-medium text-gray-300" class="block mb-2 text-sm font-medium text-gray-300"
>Background Color</label >Background Color</label
> >
<input <input type="color" id="background-color" value="#FFFFFF" />
type="color"
id="background-color"
value="#FFFFFF"
class="w-full h-10 bg-gray-700 border border-gray-600 rounded-lg cursor-pointer"
/>
</div> </div>
<div class="flex items-end"> <div class="flex items-end">
<label class="flex items-center gap-2 cursor-pointer"> <label class="flex items-center gap-2 cursor-pointer">
@@ -261,12 +256,7 @@
class="block mb-2 text-sm font-medium text-gray-300" class="block mb-2 text-sm font-medium text-gray-300"
>Separator Color</label >Separator Color</label
> >
<input <input type="color" id="separator-color" value="#000000" />
type="color"
id="separator-color"
value="#000000"
class="w-full h-10 bg-gray-700 border border-gray-600 rounded-lg cursor-pointer"
/>
</div> </div>
</div> </div>
+1 -6
View File
@@ -477,12 +477,7 @@
class="block text-sm font-medium text-gray-300 mb-2" class="block text-sm font-medium text-gray-300 mb-2"
>Text Color</label >Text Color</label
> >
<input <input type="color" id="sig-text-color" value="#000000" />
type="color"
id="sig-text-color"
value="#000000"
class="w-full h-10 bg-gray-700 border border-gray-600 rounded-lg cursor-pointer"
/>
</div> </div>
<div> <div>
<label <label
+1 -6
View File
@@ -296,12 +296,7 @@
>Background Color</label >Background Color</label
> >
<div class="flex items-center gap-3"> <div class="flex items-center gap-3">
<input <input type="color" id="background-color" value="#ffffff" />
type="color"
id="background-color"
value="#ffffff"
class="h-10 w-20 rounded border border-gray-600 bg-gray-700 cursor-pointer"
/>
<span class="text-sm text-gray-400" <span class="text-sm text-gray-400"
>Color for margins/padding</span >Color for margins/padding</span
> >
+1 -6
View File
@@ -202,12 +202,7 @@
class="block mb-2 text-sm font-medium text-gray-300" class="block mb-2 text-sm font-medium text-gray-300"
>Font Color</label >Font Color</label
> >
<input <input type="color" id="font-color" value="#000000" class="" />
type="color"
id="font-color"
value="#000000"
class="w-full h-[42px] bg-gray-700 border border-gray-600 rounded-lg p-1 cursor-pointer"
/>
</div> </div>
</div> </div>
</div> </div>
+1 -6
View File
@@ -253,12 +253,7 @@
class="block mb-2 text-sm font-medium text-gray-300" class="block mb-2 text-sm font-medium text-gray-300"
>Border Color</label >Border Color</label
> >
<input <input type="color" id="border-color" value="#000000" />
type="color"
id="border-color"
value="#000000"
class="w-full h-10 bg-gray-700 border border-gray-600 rounded-lg cursor-pointer"
/>
</div> </div>
</div> </div>

Some files were not shown because too many files have changed in this diff Show More