diff --git a/.env.example b/.env.example index 7a79124..2cce17b 100644 --- a/.env.example +++ b/.env.example @@ -15,3 +15,10 @@ 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= diff --git a/.prettierignore b/.prettierignore new file mode 100644 index 0000000..e70c3cd --- /dev/null +++ b/.prettierignore @@ -0,0 +1,2 @@ +# Handlebars partials with template syntax inside HTML attributes +src/partials/footer.html diff --git a/Dockerfile b/Dockerfile index 6ff58c6..af6375d 100644 --- a/Dockerfile +++ b/Dockerfile @@ -39,6 +39,14 @@ ENV VITE_WASM_CPDF_URL=$VITE_WASM_CPDF_URL 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 diff --git a/Dockerfile.nonroot b/Dockerfile.nonroot index 0cf3b6b..5581595 100644 --- a/Dockerfile.nonroot +++ b/Dockerfile.nonroot @@ -36,6 +36,14 @@ ENV VITE_WASM_CPDF_URL=$VITE_WASM_CPDF_URL 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 diff --git a/README.md b/README.md index f280b2e..acd3d51 100644 --- a/README.md +++ b/README.md @@ -38,6 +38,7 @@ - [Docker Compose / Podman Compose](#-run-with-docker-compose--podman-compose-recommended) - [Podman Quadlet](#-podman-quadlet-systemd-integration) - [Simple Mode](#-simple-mode-for-internal-use) + - [Custom Branding](#-custom-branding) - [WASM Configuration](#wasm-configuration) - [Air-Gapped / Offline Deployment](#air-gapped--offline-deployment) - [Security Features](#-security-features) @@ -472,7 +473,69 @@ Users can also override these defaults per-browser via **Advanced Settings** in
` | Default UI language (e.g. `fr`, `de`) | _(none)_ |
+| `--brand-name ` | Custom brand name | _(none)_ |
+| `--brand-logo ` | Logo path relative to `public/` | _(none)_ |
+| `--footer-text ` | Custom footer text | _(none)_ |
+| `--dockerfile ` | Dockerfile to use | `Dockerfile` |
+| `--skip-docker` | Skip Docker build and export | off |
+| `--skip-wasm` | Skip WASM download (reuse existing `.tgz` files) | off |
+
+ ` | Default UI language (e.g. `fr`, `de`) | _(none)_ |
+| `--brand-name ` | Custom brand name | _(none)_ |
+| `--brand-logo ` | Logo path relative to `public/` | _(none)_ |
+| `--footer-text ` | Custom footer text | _(none)_ |
+| `--dockerfile ` | 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
+
+
+If you prefer to do it manually without the script
+
**Step 1: Download the WASM packages** (on a machine with internet)
```bash
@@ -206,17 +296,19 @@ Copy via USB, internal artifact repo, or approved transfer method:
# Load the Docker image
docker load -i bentopdf.tar
-# Extract WASM packages to your internal web server's document root
-mkdir -p /var/www/wasm/pymupdf /var/www/wasm/gs /var/www/wasm/cpdf
-tar xzf bentopdf-pymupdf-wasm-0.11.14.tgz -C /var/www/wasm/pymupdf --strip-components=1
-tar xzf bentopdf-gs-wasm-*.tgz -C /var/www/wasm/gs --strip-components=1
-tar xzf coherentpdf-*.tgz -C /var/www/wasm/cpdf --strip-components=1
+# Extract WASM packages
+mkdir -p ./wasm/pymupdf ./wasm/gs ./wasm/cpdf
+tar xzf bentopdf-pymupdf-wasm-0.11.14.tgz -C ./wasm/pymupdf --strip-components=1
+tar xzf bentopdf-gs-wasm-*.tgz -C ./wasm/gs --strip-components=1
+tar xzf coherentpdf-*.tgz -C ./wasm/cpdf --strip-components=1
# Run 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.
+
+
::: info Building from source instead of Docker?
Set the variables in `.env.production` before running `npm run build`:
diff --git a/docs/self-hosting/netlify.md b/docs/self-hosting/netlify.md
index 910fa23..c058566 100644
--- a/docs/self-hosting/netlify.md
+++ b/docs/self-hosting/netlify.md
@@ -17,11 +17,11 @@
### Step 2: Configure Build Settings
-| Setting | Value |
-|---------|-------|
-| Build command | `npm run build` |
-| Publish directory | `dist` |
-| Node version | 18+ |
+| Setting | Value |
+| ----------------- | --------------- |
+| Build command | `npm run build` |
+| Publish directory | `dist` |
+| Node version | 18+ |
### Step 3: Deploy
@@ -39,27 +39,61 @@ Create `netlify.toml` in your project root:
[build.environment]
NODE_VERSION = "18"
-# SPA routing
-[[redirects]]
- from = "/*"
- to = "/index.html"
- status = 200
+# Required security headers for SharedArrayBuffer (used by LibreOffice WASM)
+[[headers]]
+ for = "/*"
+ [headers.values]
+ 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]]
for = "*.wasm"
[headers.values]
Cache-Control = "public, max-age=31536000, immutable"
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
Set these in Site settings → Environment variables:
-| Variable | Description |
-|----------|-------------|
-| `SIMPLE_MODE` | Set to `true` for minimal build |
+| Variable | Description |
+| ----------------------- | ----------------------------------------------------------- |
+| `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
@@ -78,6 +112,19 @@ git lfs track "*.wasm"
## 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
Check Node version compatibility:
diff --git a/docs/self-hosting/nginx.md b/docs/self-hosting/nginx.md
index 35b0612..79c666b 100644
--- a/docs/self-hosting/nginx.md
+++ b/docs/self-hosting/nginx.md
@@ -17,6 +17,12 @@ npm install
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
```bash
@@ -57,21 +63,51 @@ server {
application/wasm wasm;
}
- # Cache static assets
- location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|wasm)$ {
- expires 1y;
- add_header Cache-Control "public, immutable";
- }
-
- # SPA routing - serve index.html for all routes
- location / {
- try_files $uri $uri/ /index.html;
- }
+ # Required headers for SharedArrayBuffer (LibreOffice WASM)
+ # These must be on every response - especially HTML pages
+ add_header Cross-Origin-Embedder-Policy "require-corp" always;
+ add_header Cross-Origin-Opener-Policy "same-origin" always;
+ add_header Cross-Origin-Resource-Policy "cross-origin" always;
# Security headers
add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-Content-Type-Options "nosniff" 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;
+ }
}
```
@@ -120,7 +156,7 @@ http {
# Increase buffer sizes
client_max_body_size 100M;
-
+
# Worker connections
worker_connections 2048;
}
@@ -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
Check Nginx error logs:
diff --git a/docs/self-hosting/vercel.md b/docs/self-hosting/vercel.md
index ed0b9e5..e8d55da 100644
--- a/docs/self-hosting/vercel.md
+++ b/docs/self-hosting/vercel.md
@@ -18,20 +18,24 @@ Fork [bentopdf/bentopdf](https://github.com/alam00000/bentopdf) to your GitHub a
2. Select your forked repository
3. Configure the project:
-| Setting | Value |
-|---------|-------|
-| Framework Preset | Vite |
-| Build Command | `npm run build` |
-| Output Directory | `dist` |
-| Install Command | `npm install` |
+| Setting | Value |
+| ---------------- | --------------- |
+| Framework Preset | Vite |
+| Build Command | `npm run build` |
+| Output Directory | `dist` |
+| Install Command | `npm install` |
### Step 3: Environment Variables (Optional)
Add these if needed:
-```
-SIMPLE_MODE=false
-```
+| Variable | Description |
+| ----------------------- | ----------------------------------------------------------- |
+| `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
@@ -73,3 +77,46 @@ Add a `vercel.json` for SPA routing:
"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`.
diff --git a/public/locales/ar/common.json b/public/locales/ar/common.json
index e8da0b7..133e022 100644
--- a/public/locales/ar/common.json
+++ b/public/locales/ar/common.json
@@ -357,5 +357,9 @@
"errorRendering": "فشل عرض الصور المصغرة للصفحات",
"error": "خطأ",
"failedToLoad": "فشل التحميل"
+ },
+ "simpleMode": {
+ "title": "أدوات PDF",
+ "subtitle": "اختر أداة للبدء"
}
}
diff --git a/public/locales/be/common.json b/public/locales/be/common.json
index 7376314..438dea7 100644
--- a/public/locales/be/common.json
+++ b/public/locales/be/common.json
@@ -355,5 +355,9 @@
},
"relatedTools": {
"title": "Звязаныя інструменты PDF"
+ },
+ "simpleMode": {
+ "title": "Інструменты PDF",
+ "subtitle": "Абярыце інструмент, каб пачаць"
}
}
diff --git a/public/locales/da/common.json b/public/locales/da/common.json
index f8e098d..6710815 100644
--- a/public/locales/da/common.json
+++ b/public/locales/da/common.json
@@ -357,5 +357,9 @@
"errorRendering": "Kunne ikke rendere side-miniaturer",
"error": "Fejl",
"failedToLoad": "Kunne ikke indlæses"
+ },
+ "simpleMode": {
+ "title": "PDF-værktøjer",
+ "subtitle": "Vælg et værktøj for at komme i gang"
}
}
diff --git a/public/locales/de/common.json b/public/locales/de/common.json
index cb39144..70ec209 100644
--- a/public/locales/de/common.json
+++ b/public/locales/de/common.json
@@ -357,5 +357,9 @@
},
"relatedTools": {
"title": "Verwandte PDF-Tools"
+ },
+ "simpleMode": {
+ "title": "PDF-Werkzeuge",
+ "subtitle": "Wählen Sie ein Werkzeug aus, um zu beginnen"
}
}
diff --git a/public/locales/en/common.json b/public/locales/en/common.json
index aaf1455..475ee64 100644
--- a/public/locales/en/common.json
+++ b/public/locales/en/common.json
@@ -357,5 +357,9 @@
"errorRendering": "Failed to render page thumbnails",
"error": "Error",
"failedToLoad": "Failed to load"
+ },
+ "simpleMode": {
+ "title": "PDF Tools",
+ "subtitle": "Select a tool to get started"
}
}
diff --git a/public/locales/es/common.json b/public/locales/es/common.json
index 71e0f3a..690ba7b 100644
--- a/public/locales/es/common.json
+++ b/public/locales/es/common.json
@@ -357,5 +357,9 @@
},
"relatedTools": {
"title": "Herramientas PDF relacionadas"
+ },
+ "simpleMode": {
+ "title": "Herramientas PDF",
+ "subtitle": "Selecciona una herramienta para comenzar"
}
}
diff --git a/public/locales/fr/common.json b/public/locales/fr/common.json
index a1054ab..cbd7211 100644
--- a/public/locales/fr/common.json
+++ b/public/locales/fr/common.json
@@ -357,5 +357,9 @@
},
"relatedTools": {
"title": "Outils PDF associés"
+ },
+ "simpleMode": {
+ "title": "Outils PDF",
+ "subtitle": "Sélectionnez un outil pour commencer"
}
}
diff --git a/public/locales/id/common.json b/public/locales/id/common.json
index 6fbc93c..f178594 100644
--- a/public/locales/id/common.json
+++ b/public/locales/id/common.json
@@ -357,5 +357,9 @@
},
"relatedTools": {
"title": "Alat PDF Terkait"
+ },
+ "simpleMode": {
+ "title": "Alat PDF",
+ "subtitle": "Pilih alat untuk memulai"
}
}
diff --git a/public/locales/it/common.json b/public/locales/it/common.json
index 573a9d4..e938a8a 100644
--- a/public/locales/it/common.json
+++ b/public/locales/it/common.json
@@ -357,5 +357,9 @@
},
"relatedTools": {
"title": "Strumenti PDF correlati"
+ },
+ "simpleMode": {
+ "title": "Strumenti PDF",
+ "subtitle": "Seleziona uno strumento per iniziare"
}
}
diff --git a/public/locales/nl/common.json b/public/locales/nl/common.json
index 27cfb70..348ada3 100644
--- a/public/locales/nl/common.json
+++ b/public/locales/nl/common.json
@@ -357,5 +357,9 @@
},
"relatedTools": {
"title": "Gerelateerde PDF-tools"
+ },
+ "simpleMode": {
+ "title": "PDF-tools",
+ "subtitle": "Selecteer een tool om te beginnen"
}
}
diff --git a/public/locales/pt/common.json b/public/locales/pt/common.json
index 04879ed..632ebb9 100644
--- a/public/locales/pt/common.json
+++ b/public/locales/pt/common.json
@@ -357,5 +357,9 @@
},
"relatedTools": {
"title": "Ferramentas PDF relacionadas"
+ },
+ "simpleMode": {
+ "title": "Ferramentas PDF",
+ "subtitle": "Selecione uma ferramenta para começar"
}
}
diff --git a/public/locales/tr/common.json b/public/locales/tr/common.json
index d344824..ca6fceb 100644
--- a/public/locales/tr/common.json
+++ b/public/locales/tr/common.json
@@ -357,5 +357,9 @@
},
"relatedTools": {
"title": "İlgili PDF Araçları"
+ },
+ "simpleMode": {
+ "title": "PDF Araçları",
+ "subtitle": "Başlamak için bir araç seçin"
}
}
diff --git a/public/locales/vi/common.json b/public/locales/vi/common.json
index a778bff..0a57f66 100644
--- a/public/locales/vi/common.json
+++ b/public/locales/vi/common.json
@@ -357,5 +357,9 @@
},
"relatedTools": {
"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"
}
}
diff --git a/public/locales/zh-TW/common.json b/public/locales/zh-TW/common.json
index ea07a4d..7b10dc4 100644
--- a/public/locales/zh-TW/common.json
+++ b/public/locales/zh-TW/common.json
@@ -357,5 +357,9 @@
},
"relatedTools": {
"title": "相關 PDF 工具"
+ },
+ "simpleMode": {
+ "title": "PDF 工具",
+ "subtitle": "選擇一個工具開始使用"
}
}
diff --git a/public/locales/zh/common.json b/public/locales/zh/common.json
index 9458f95..a3b76bd 100644
--- a/public/locales/zh/common.json
+++ b/public/locales/zh/common.json
@@ -357,5 +357,9 @@
},
"relatedTools": {
"title": "相关 PDF 工具"
+ },
+ "simpleMode": {
+ "title": "PDF 工具",
+ "subtitle": "选择一个工具开始使用"
}
}
diff --git a/scripts/prepare-airgap.sh b/scripts/prepare-airgap.sh
new file mode 100755
index 0000000..b87d54c
--- /dev/null
+++ b/scripts/prepare-airgap.sh
@@ -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 Base URL where WASM files will be hosted
+ in the air-gapped network
+ (e.g. https://internal.example.com/wasm)
+
+OPTIONS:
+ --image-name Docker image name (default: bentopdf)
+ --output-dir Output bundle directory (default: ./bentopdf-airgap-bundle)
+ --dockerfile Dockerfile to use (default: Dockerfile)
+ --simple-mode Enable Simple Mode
+ --base-url Subdirectory base URL (e.g. /pdf/)
+ --compression Compression: g, b, o, all (default: all)
+ --language Default UI language (e.g. fr, de, es)
+ --brand-name Custom brand name
+ --brand-logo Logo path relative to public/
+ --footer-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" </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" <
@@ -460,14 +462,17 @@
- BentoPDF
+ {{#if brandName}}{{brandName}}{{else}}BentoPDF{{/if}}
- © 2026 BentoPDF. All rights reserved.
+ {{#if footerText}}{{footerText}}{{else}}© 2026 BentoPDF. All
+ rights reserved.{{/if}}
Version
diff --git a/src/js/main.ts b/src/js/main.ts
index afffd92..d44d7e9 100644
--- a/src/js/main.ts
+++ b/src/js/main.ts
@@ -15,6 +15,7 @@ import {
createLanguageSwitcher,
t,
} from './i18n/index.js';
+declare const __BRAND_NAME__: string;
const init = async () => {
await initI18n();
@@ -81,18 +82,19 @@ const init = async () => {
(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');
if (toolsHeader) {
const title = toolsHeader.querySelector('h2');
const subtitle = toolsHeader.querySelector('p');
if (title) {
- title.textContent = 'PDF Tools';
+ title.textContent = t('simpleMode.title');
title.className = 'text-4xl md:text-5xl font-bold text-white mb-3';
}
if (subtitle) {
- subtitle.textContent = 'Select a tool to get started';
+ subtitle.textContent = t('simpleMode.subtitle');
subtitle.className = 'text-lg text-gray-400';
}
}
@@ -805,8 +807,12 @@ const init = async () => {
left.className = 'flex items-center gap-3';
const icon = document.createElement('i');
- icon.className = 'w-5 h-5 text-indigo-400';
- icon.setAttribute('data-lucide', tool.icon);
+ if (tool.icon.startsWith('ph-')) {
+ 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');
name.className = 'text-gray-200 font-medium';
diff --git a/src/js/workflow/nodes/divide-pages-node.ts b/src/js/workflow/nodes/divide-pages-node.ts
index d5d8dd1..1334697 100644
--- a/src/js/workflow/nodes/divide-pages-node.ts
+++ b/src/js/workflow/nodes/divide-pages-node.ts
@@ -4,6 +4,7 @@ import { pdfSocket } from '../sockets';
import type { SocketData } from '../types';
import { requirePdfInput, processBatch } from '../types';
import { PDFDocument } from 'pdf-lib';
+import { parsePageRange } from '../../utils/pdf-operations';
export class DividePagesNode extends BaseWorkflowNode {
readonly category = 'Organize & Manage' as const;
@@ -18,6 +19,10 @@ export class DividePagesNode extends BaseWorkflowNode {
'direction',
new ClassicPreset.InputControl('text', { initial: 'vertical' })
);
+ this.addControl(
+ 'pages',
+ new ClassicPreset.InputControl('text', { initial: '' })
+ );
}
async data(
@@ -30,23 +35,39 @@ export class DividePagesNode extends BaseWorkflowNode {
const direction =
dirCtrl?.value === 'horizontal' ? 'horizontal' : 'vertical';
+ const pagesCtrl = this.controls['pages'] as
+ | ClassicPreset.InputControl<'text'>
+ | undefined;
+ const rangeStr = (pagesCtrl?.value || '').trim();
+
return {
pdf: await processBatch(pdfInputs, async (input) => {
const srcDoc = await PDFDocument.load(input.bytes);
const newDoc = await PDFDocument.create();
- for (let i = 0; i < srcDoc.getPageCount(); 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);
+ const totalPages = srcDoc.getPageCount();
+
+ const pagesToDivide: Set = rangeStr
+ ? new Set(parsePageRange(rangeStr, totalPages))
+ : new Set(Array.from({ length: totalPages }, (_, i) => i));
+
+ 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 {
- page1.setCropBox(0, height / 2, width, height / 2);
- page2.setCropBox(0, 0, width, height / 2);
+ const [copiedPage] = await newDoc.copyPages(srcDoc, [i]);
+ newDoc.addPage(copiedPage);
}
- newDoc.addPage(page1);
- newDoc.addPage(page2);
}
const pdfBytes = await newDoc.save();
return {
diff --git a/src/pages/pdf-multi-tool.html b/src/pages/pdf-multi-tool.html
index 68abb18..da35359 100644
--- a/src/pages/pdf-multi-tool.html
+++ b/src/pages/pdf-multi-tool.html
@@ -133,12 +133,14 @@
- BentoPDF
+ {{#if brandName}}{{brandName}}{{else}}BentoPDF{{/if}}
PDF Multi Tool
- BentoPDF
+
-
- © 2026 BentoPDF. All rights reserved.
+
Version
diff --git a/src/partials/footer.html b/src/partials/footer.html
index 78a88ab..1298255 100644
--- a/src/partials/footer.html
+++ b/src/partials/footer.html
@@ -5,14 +5,15 @@
- BentoPDF
+
-
- © 2026 BentoPDF. All rights reserved.
+
Version
diff --git a/src/partials/navbar-simple.html b/src/partials/navbar-simple.html
index 1b1021b..a3d174c 100644
--- a/src/partials/navbar-simple.html
+++ b/src/partials/navbar-simple.html
@@ -6,12 +6,15 @@
-
- BentoPDF
+
diff --git a/src/partials/navbar.html b/src/partials/navbar.html
index 6f97076..ae540cf 100644
--- a/src/partials/navbar.html
+++ b/src/partials/navbar.html
@@ -7,12 +7,15 @@
id="home-logo"
>
-
- BentoPDF
+
diff --git a/vite.config.ts b/vite.config.ts
index 0ee2df8..45b9017 100644
--- a/vite.config.ts
+++ b/vite.config.ts
@@ -293,6 +293,9 @@ export default defineConfig(() => {
context: {
baseUrl: (process.env.BASE_URL || '/').replace(/\/?$/, '/'),
simpleMode: process.env.SIMPLE_MODE === 'true',
+ brandName: process.env.VITE_BRAND_NAME || '',
+ brandLogo: process.env.VITE_BRAND_LOGO || '',
+ footerText: process.env.VITE_FOOTER_TEXT || '',
},
}),
languageRouterPlugin(),
@@ -334,6 +337,7 @@ export default defineConfig(() => {
],
define: {
__SIMPLE_MODE__: JSON.stringify(process.env.SIMPLE_MODE === 'true'),
+ __BRAND_NAME__: JSON.stringify(process.env.VITE_BRAND_NAME || ''),
},
resolve: {
alias: {