feat: add custom branding, air-gapped deployment script, and updated self-hosting docs

This commit is contained in:
alam00000
2026-02-14 21:38:58 +05:30
parent 75b1d67fbd
commit 3cf435d59d
38 changed files with 1487 additions and 123 deletions

View File

@@ -15,3 +15,10 @@ VITE_WASM_CPDF_URL=https://cdn.jsdelivr.net/npm/coherentpdf/dist/
# Default UI language (build-time) # Default UI language (build-time)
# Supported: en, ar, be, fr, de, es, zh, zh-TW, vi, tr, id, it, pt, nl, da # Supported: en, ar, be, fr, de, es, zh, zh-TW, vi, tr, id, it, pt, nl, da
VITE_DEFAULT_LANGUAGE= 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
.prettierignore Normal file
View File

@@ -0,0 +1,2 @@
# Handlebars partials with template syntax inside HTML attributes
src/partials/footer.html

View File

@@ -39,6 +39,14 @@ ENV VITE_WASM_CPDF_URL=$VITE_WASM_CPDF_URL
ARG VITE_DEFAULT_LANGUAGE ARG VITE_DEFAULT_LANGUAGE
ENV VITE_DEFAULT_LANGUAGE=$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

View File

@@ -36,6 +36,14 @@ ENV VITE_WASM_CPDF_URL=$VITE_WASM_CPDF_URL
ARG VITE_DEFAULT_LANGUAGE ARG VITE_DEFAULT_LANGUAGE
ENV VITE_DEFAULT_LANGUAGE=$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

123
README.md
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)
@@ -472,7 +473,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 +545,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 +580,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`:
@@ -684,6 +741,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:

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

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:
@@ -95,7 +152,7 @@ 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 |

View File

@@ -11,7 +11,7 @@
## 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` |
@@ -22,15 +22,37 @@
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:
``` ```
@@ -90,7 +116,7 @@ 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) |

View File

@@ -96,6 +96,9 @@ docker run -d -p 3000:8080 bentopdf:custom
| `VITE_WASM_GS_URL` | Ghostscript WASM module URL | `https://cdn.jsdelivr.net/npm/@bentopdf/gs-wasm/assets/` | | `VITE_WASM_GS_URL` | Ghostscript WASM module URL | `https://cdn.jsdelivr.net/npm/@bentopdf/gs-wasm/assets/` |
| `VITE_WASM_CPDF_URL` | CoherentPDF WASM module URL | `https://cdn.jsdelivr.net/npm/coherentpdf/dist/` | | `VITE_WASM_CPDF_URL` | CoherentPDF WASM module URL | `https://cdn.jsdelivr.net/npm/coherentpdf/dist/` |
| `VITE_DEFAULT_LANGUAGE` | Default UI language | `en` | | `VITE_DEFAULT_LANGUAGE` | Default UI language | `en` |
| `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.
@@ -109,6 +112,20 @@ docker build --build-arg VITE_DEFAULT_LANGUAGE=fr -t bentopdf .
docker run -d -p 3000:8080 bentopdf docker run -d -p 3000:8080 bentopdf
``` ```
### 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]

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:
@@ -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`:

View File

@@ -18,7 +18,7 @@
### 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+ |
@@ -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:

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:

View File

@@ -19,7 +19,7 @@ Fork [bentopdf/bentopdf](https://github.com/alam00000/bentopdf) to your GitHub a
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` |
@@ -29,9 +29,13 @@ Fork [bentopdf/bentopdf](https://github.com/alam00000/bentopdf) to your GitHub a
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`.

View File

@@ -357,5 +357,9 @@
"errorRendering": "فشل عرض الصور المصغرة للصفحات", "errorRendering": "فشل عرض الصور المصغرة للصفحات",
"error": "خطأ", "error": "خطأ",
"failedToLoad": "فشل التحميل" "failedToLoad": "فشل التحميل"
},
"simpleMode": {
"title": "أدوات PDF",
"subtitle": "اختر أداة للبدء"
} }
} }

View File

@@ -355,5 +355,9 @@
}, },
"relatedTools": { "relatedTools": {
"title": "Звязаныя інструменты PDF" "title": "Звязаныя інструменты PDF"
},
"simpleMode": {
"title": "Інструменты PDF",
"subtitle": "Абярыце інструмент, каб пачаць"
} }
} }

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"
} }
} }

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"
} }
} }

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"
} }
} }

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"
} }
} }

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"
} }
} }

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"
} }
} }

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"
} }
} }

View File

@@ -357,5 +357,9 @@
}, },
"relatedTools": { "relatedTools": {
"title": "Gerelateerde PDF-tools" "title": "Gerelateerde PDF-tools"
},
"simpleMode": {
"title": "PDF-tools",
"subtitle": "Selecteer een tool om te beginnen"
} }
} }

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"
} }
} }

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"
} }
} }

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"
} }
} }

View File

@@ -357,5 +357,9 @@
}, },
"relatedTools": { "relatedTools": {
"title": "相關 PDF 工具" "title": "相關 PDF 工具"
},
"simpleMode": {
"title": "PDF 工具",
"subtitle": "選擇一個工具開始使用"
} }
} }

View File

@@ -357,5 +357,9 @@
}, },
"relatedTools": { "relatedTools": {
"title": "相关 PDF 工具" "title": "相关 PDF 工具"
},
"simpleMode": {
"title": "PDF 工具",
"subtitle": "选择一个工具开始使用"
} }
} }

755
scripts/prepare-airgap.sh Executable file
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!"

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>
@@ -460,14 +462,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>

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';
} }
} }
@@ -805,8 +807,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');
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.className = 'w-5 h-5 text-indigo-400';
icon.setAttribute('data-lucide', tool.icon); 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';

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,11 +35,23 @@ 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 pagesToDivide: Set<number> = 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 [page1] = await newDoc.copyPages(srcDoc, [i]);
const [page2] = await newDoc.copyPages(srcDoc, [i]); const [page2] = await newDoc.copyPages(srcDoc, [i]);
const { width, height } = page1.getSize(); const { width, height } = page1.getSize();
@@ -47,6 +64,10 @@ export class DividePagesNode extends BaseWorkflowNode {
} }
newDoc.addPage(page1); newDoc.addPage(page1);
newDoc.addPage(page2); newDoc.addPage(page2);
} else {
const [copiedPage] = await newDoc.copyPages(srcDoc, [i]);
newDoc.addPage(copiedPage);
}
} }
const pdfBytes = await newDoc.save(); const pdfBytes = await newDoc.save();
return { return {

View File

@@ -133,12 +133,14 @@
<div class="flex justify-between items-center h-16"> <div class="flex justify-between items-center h-16">
<div class="flex-shrink-0 flex items-center"> <div class="flex-shrink-0 flex items-center">
<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>
<span class="text-gray-400 ml-3 text-sm sm:text-base" <span class="text-gray-400 ml-3 text-sm sm:text-base"
>PDF Multi Tool</span >PDF Multi Tool</span

View File

@@ -4,14 +4,18 @@
<div> <div>
<div class="flex items-center mb-2"> <div class="flex items-center mb-2">
<img <img
src="{{baseUrl}}images/favicon.svg" id="footer-logo"
alt="Bento PDF Logo" src="{{baseUrl}}{{#if brandLogo}}{{brandLogo}}{{else}}images/favicon.svg{{/if}}"
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 id="footer-brand" class="text-white font-bold text-lg"
>{{#if brandName}}{{brandName}}{{else}}BentoPDF{{/if}}</span
>
</div> </div>
<p class="text-gray-400 text-sm"> <p id="footer-copyright" 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>

View File

@@ -5,14 +5,15 @@
<div class="mb-8 md:mb-0"> <div class="mb-8 md:mb-0">
<div class="flex items-center justify-center md:justify-start mb-4"> <div class="flex items-center justify-center md:justify-start mb-4">
<img <img
src="{{baseUrl}}images/favicon.svg" id="footer-logo"
alt="Bento PDF Logo" src="{{baseUrl}}{{#if brandLogo}}{{brandLogo}}{{else}}images/favicon.svg{{/if}}"
alt="{{#if brandName}}{{brandName}}{{else}}Bento PDF{{/if}} Logo"
class="h-10 w-10 mr-3" class="h-10 w-10 mr-3"
/> />
<span class="text-xl font-bold text-white">BentoPDF</span> <span id="footer-brand" class="text-xl font-bold text-white">{{#if brandName}}{{brandName}}{{else}}BentoPDF{{/if}}</span>
</div> </div>
<p class="text-gray-400 text-sm" data-i18n="footer.copyright"> <p id="footer-copyright" class="text-gray-400 text-sm" {{#unless footerText}}data-i18n="footer.copyright"{{/unless}}>
&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">
<span data-i18n="footer.version">Version</span> <span data-i18n="footer.version">Version</span>

View File

@@ -6,12 +6,15 @@
<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="{{baseUrl}}images/favicon-no-bg.svg" id="nav-logo"
alt="Bento PDF Logo" src="{{baseUrl}}{{#if brandLogo}}{{brandLogo}}{{else}}images/favicon-no-bg.svg{{/if}}"
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 id="nav-brand" class="text-white font-bold text-xl ml-2">
<a href="{{baseUrl}}">BentoPDF</a> <a href="{{baseUrl}}"
>{{#if brandName}}{{brandName}}{{else}}BentoPDF{{/if}}</a
>
</span> </span>
</div> </div>
</div> </div>

View File

@@ -7,12 +7,15 @@
id="home-logo" id="home-logo"
> >
<img <img
src="{{baseUrl}}images/favicon-no-bg.svg" id="nav-logo"
alt="Bento PDF Logo" src="{{baseUrl}}{{#if brandLogo}}{{brandLogo}}{{else}}images/favicon-no-bg.svg{{/if}}"
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 id="nav-brand" class="text-white font-bold text-xl ml-2">
<a href="index.html">BentoPDF</a> <a href="index.html"
>{{#if brandName}}{{brandName}}{{else}}BentoPDF{{/if}}</a
>
</span> </span>
</div> </div>

View File

@@ -293,6 +293,9 @@ export default defineConfig(() => {
context: { context: {
baseUrl: (process.env.BASE_URL || '/').replace(/\/?$/, '/'), baseUrl: (process.env.BASE_URL || '/').replace(/\/?$/, '/'),
simpleMode: process.env.SIMPLE_MODE === 'true', 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(), languageRouterPlugin(),
@@ -334,6 +337,7 @@ export default defineConfig(() => {
], ],
define: { define: {
__SIMPLE_MODE__: JSON.stringify(process.env.SIMPLE_MODE === 'true'), __SIMPLE_MODE__: JSON.stringify(process.env.SIMPLE_MODE === 'true'),
__BRAND_NAME__: JSON.stringify(process.env.VITE_BRAND_NAME || ''),
}, },
resolve: { resolve: {
alias: { alias: {