diff --git a/.github/workflows/build-and-publish.yml b/.github/workflows/build-and-publish.yml index c45724e8..46e58946 100644 --- a/.github/workflows/build-and-publish.yml +++ b/.github/workflows/build-and-publish.yml @@ -45,7 +45,7 @@ jobs: run: npm run package - name: Create GitHub Release - uses: softprops/action-gh-release@v2 + uses: softprops/action-gh-release@3bb12739c298aeb8a4eeaf626c5b8d85266b0e65 # v2.6.2 with: files: | dist-${{ steps.version.outputs.version }}.zip @@ -77,10 +77,10 @@ jobs: uses: actions/checkout@v4 - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v3 + uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0 - name: Login to GitHub Container Registry - uses: docker/login-action@v3 + uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0 with: registry: ghcr.io username: ${{ github.repository_owner }} @@ -103,7 +103,7 @@ jobs: - name: Build and push amd64 ${{ matrix.mode.name }} (release) if: steps.version.outputs.is_release == 'true' - uses: docker/build-push-action@v6 + uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6.19.2 with: push: true build-args: | @@ -111,12 +111,11 @@ jobs: tags: | ghcr.io/${{ github.repository_owner }}/bentopdf${{ matrix.mode.suffix }}:${{ steps.version.outputs.version }}-amd64 platforms: linux/amd64 - cache-from: type=gha,scope=amd64-${{ matrix.mode.name }} - cache-to: type=gha,mode=max,scope=amd64-${{ matrix.mode.name }} + no-cache: true - name: Build and push amd64 ${{ matrix.mode.name }} (edge) if: steps.version.outputs.is_release == 'false' - uses: docker/build-push-action@v6 + uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6.19.2 with: push: true build-args: | @@ -146,10 +145,10 @@ jobs: uses: actions/checkout@v4 - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v3 + uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0 - name: Login to GitHub Container Registry - uses: docker/login-action@v3 + uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0 with: registry: ghcr.io username: ${{ github.repository_owner }} @@ -172,7 +171,7 @@ jobs: - name: Build and push arm64 ${{ matrix.mode.name }} (release) if: steps.version.outputs.is_release == 'true' - uses: docker/build-push-action@v6 + uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6.19.2 with: push: true build-args: | @@ -180,12 +179,11 @@ jobs: tags: | ghcr.io/${{ github.repository_owner }}/bentopdf${{ matrix.mode.suffix }}:${{ steps.version.outputs.version }}-arm64 platforms: linux/arm64 - cache-from: type=gha,scope=arm64-${{ matrix.mode.name }} - cache-to: type=gha,mode=max,scope=arm64-${{ matrix.mode.name }} + no-cache: true - name: Build and push arm64 ${{ matrix.mode.name }} (edge) if: steps.version.outputs.is_release == 'false' - uses: docker/build-push-action@v6 + uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6.19.2 with: push: true build-args: | @@ -211,7 +209,7 @@ jobs: suffix: "-simple" steps: - name: Login to GitHub Container Registry - uses: docker/login-action@v3 + uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0 with: registry: ghcr.io username: ${{ github.repository_owner }} @@ -275,14 +273,14 @@ jobs: suffix: "-simple" steps: - name: Login to GitHub Container Registry - uses: docker/login-action@v3 + uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0 with: registry: ghcr.io username: ${{ github.repository_owner }} password: ${{ secrets.GITHUB_TOKEN }} - name: Login to DockerHub - uses: docker/login-action@v3 + uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0 with: username: ${{ secrets.DOCKER_USERNAME }} password: ${{ secrets.DOCKER_TOKEN }} diff --git a/.github/workflows/cla.yml b/.github/workflows/cla.yml index 20f831ef..24ff71ac 100644 --- a/.github/workflows/cla.yml +++ b/.github/workflows/cla.yml @@ -11,7 +11,6 @@ permissions: actions: write contents: write pull-requests: write - statuses: write jobs: cla-check: @@ -28,16 +27,15 @@ jobs: ) steps: - name: "CLA Assistant" - uses: contributor-assistant/github-action@v2.6.1 + uses: contributor-assistant/github-action@ca4a40a7d1004f18d9960b404b97e5f30a505a08 # v2.6.1 env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - PERSONAL_ACCESS_TOKEN: ${{ secrets.CLA_ASSISTANT_TOKEN }} with: path-to-signatures: 'signatures/cla.json' path-to-document: 'https://github.com/${{ github.repository }}/blob/main/ICLA.md' # Branch to store signatures - branch: 'main' + branch: 'cla-signatures' # Allowlist for bot accounts (won't need to sign CLA) allowlist: 'bot*,*[bot],dependabot*,renovate*,github-actions*' diff --git a/.github/workflows/sponsors.yml b/.github/workflows/sponsors.yml index 8d261dc5..72b4ca4d 100644 --- a/.github/workflows/sponsors.yml +++ b/.github/workflows/sponsors.yml @@ -1,12 +1,30 @@ name: Update Sponsors on: workflow_dispatch: + schedule: + - cron: '0 6 * * 1' + +permissions: + contents: read + jobs: update: runs-on: ubuntu-latest + if: github.repository == 'alam00000/bentopdf' + permissions: + contents: write + pull-requests: write steps: - uses: actions/checkout@v4 - - uses: JamesIves/github-sponsors-readme-action@v1 + - uses: JamesIves/github-sponsors-readme-action@02650b8cd445fc16dfef73195f9c406dce041623 # v1.6.1 with: token: ${{ secrets.SPONSORS_TOKEN }} - file: 'README.md' \ No newline at end of file + file: 'README.md' + active-only: false + - uses: peter-evans/create-pull-request@c5a7806660adbe173f04e3e038b0ccdcd758773c # v6.1.0 + with: + commit-message: 'docs: update sponsors' + title: 'docs: update sponsors' + body: Updates the sponsors list in the README from GitHub Sponsors. + branch: chore/update-sponsors + delete-branch: true diff --git a/.github/workflows/trivy-scan.yml b/.github/workflows/trivy-scan.yml index 680c535d..1768047b 100644 --- a/.github/workflows/trivy-scan.yml +++ b/.github/workflows/trivy-scan.yml @@ -28,10 +28,10 @@ jobs: uses: actions/checkout@v4 - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v3 + uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0 - name: Build image for scanning - uses: docker/build-push-action@v6 + uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6.19.2 with: context: . file: ${{ matrix.image.file }} @@ -41,7 +41,7 @@ jobs: no-cache: true - name: Run Trivy vulnerability scanner - uses: aquasecurity/trivy-action@master + uses: aquasecurity/trivy-action@ed142fd0673e97e23eac54620cfb913e5ce36c25 # v0.36.0 with: image-ref: ${{ matrix.image.name }}:scan format: sarif @@ -51,7 +51,7 @@ jobs: - name: Upload Trivy results to GitHub Security uses: github/codeql-action/upload-sarif@v4 - if: always() + if: always() && hashFiles('trivy-*.sarif') != '' with: sarif_file: trivy-${{ matrix.image.name }}.sarif category: trivy-${{ matrix.image.name }} @@ -63,7 +63,7 @@ jobs: uses: actions/checkout@v4 - name: Scan npm dependencies with Trivy - uses: aquasecurity/trivy-action@master + uses: aquasecurity/trivy-action@ed142fd0673e97e23eac54620cfb913e5ce36c25 # v0.36.0 with: scan-type: fs scan-ref: . @@ -74,7 +74,7 @@ jobs: - name: Upload dependency scan results uses: github/codeql-action/upload-sarif@v4 - if: always() + if: always() && hashFiles('trivy-deps.sarif') != '' with: sarif_file: trivy-deps.sarif category: trivy-dependencies @@ -86,7 +86,7 @@ jobs: uses: actions/checkout@v4 - name: Scan Dockerfiles for misconfigurations - uses: aquasecurity/trivy-action@master + uses: aquasecurity/trivy-action@ed142fd0673e97e23eac54620cfb913e5ce36c25 # v0.36.0 with: scan-type: config scan-ref: . @@ -96,7 +96,7 @@ jobs: - name: Upload config scan results uses: github/codeql-action/upload-sarif@v4 - if: always() + if: always() && hashFiles('trivy-config.sarif') != '' with: sarif_file: trivy-config.sarif category: trivy-config diff --git a/.github/workflows/update-bentopdf-viewer.yml b/.github/workflows/update-bentopdf-viewer.yml index 7f0136ca..c8a44e2d 100644 --- a/.github/workflows/update-bentopdf-viewer.yml +++ b/.github/workflows/update-bentopdf-viewer.yml @@ -6,8 +6,7 @@ on: - cron: '17 4 * * *' permissions: - contents: write - pull-requests: write + contents: read concurrency: group: update-engine @@ -18,12 +17,19 @@ env: HUSKY: 0 jobs: - update: + build: runs-on: ubuntu-latest if: github.repository == 'alam00000/bentopdf' + permissions: + contents: read + outputs: + run: ${{ steps.gate.outputs.run }} + version: ${{ steps.gate.outputs.version }} steps: - name: Checkout repo uses: actions/checkout@v4 + with: + persist-credentials: false - name: Read current upstream marker id: current @@ -40,13 +46,21 @@ jobs: - name: Should update? id: gate + env: + CURRENT: ${{ steps.current.outputs.version }} + UPSTREAM: ${{ steps.upstream.outputs.version }} run: | - if [ "${{ steps.upstream.outputs.version }}" = "${{ steps.current.outputs.version }}" ]; then + if ! [[ "$UPSTREAM" =~ ^[0-9a-f]{12}$ ]]; then + echo "::error::Unexpected fork HEAD: $UPSTREAM" + exit 1 + fi + echo "version=$UPSTREAM" >> "$GITHUB_OUTPUT" + if [ "$UPSTREAM" = "$CURRENT" ]; then echo "run=false" >> "$GITHUB_OUTPUT" - echo "Already on ${{ steps.current.outputs.version }}." + echo "Already on $CURRENT." else echo "run=true" >> "$GITHUB_OUTPUT" - echo "Updating ${{ steps.current.outputs.version }} -> ${{ steps.upstream.outputs.version }}" + echo "Updating $CURRENT -> $UPSTREAM" fi - name: Setup Node @@ -73,9 +87,10 @@ jobs: - name: Pack the engine if: steps.gate.outputs.run == 'true' id: engine + env: + SHA: ${{ steps.gate.outputs.version }} run: | set -euo pipefail - SHA=${{ steps.upstream.outputs.version }} VENDOR=./_fork/packages/pdfium/src/vendor STAGE=$(mktemp -d)/package mkdir -p "$STAGE" @@ -107,9 +122,10 @@ jobs: - name: Pack the viewer without a second engine if: steps.gate.outputs.run == 'true' id: viewer + env: + SHA: ${{ steps.gate.outputs.version }} run: | set -euo pipefail - SHA=${{ steps.upstream.outputs.version }} WORK=$(mktemp -d) (cd ./_fork && npm pack ./viewers/snippet --pack-destination "$WORK" >/dev/null) TMP=$(mktemp -d) @@ -137,14 +153,17 @@ jobs: - name: Point dependencies at the new tarballs if: steps.gate.outputs.run == 'true' + env: + ENGINE_TARBALL: ${{ steps.engine.outputs.tarball }} + VIEWER_TARBALL: ${{ steps.viewer.outputs.tarball }} run: | node -e " const fs = require('fs'); const pkg = JSON.parse(fs.readFileSync('package.json', 'utf8')); - pkg.dependencies['bentopdf-pdfium'] = 'file:' + process.argv[1]; - pkg.dependencies['bentopdf-viewer'] = 'file:' + process.argv[2]; + pkg.dependencies['bentopdf-pdfium'] = 'file:' + process.env.ENGINE_TARBALL; + pkg.dependencies['bentopdf-viewer'] = 'file:' + process.env.VIEWER_TARBALL; fs.writeFileSync('package.json', JSON.stringify(pkg, null, 2) + '\n'); - " "${{ steps.engine.outputs.tarball }}" "${{ steps.viewer.outputs.tarball }}" + " - name: Cleanup upstream clone if: steps.gate.outputs.run == 'true' @@ -174,19 +193,66 @@ jobs: - name: Write upstream marker if: steps.gate.outputs.run == 'true' + env: + SHA: ${{ steps.gate.outputs.version }} run: | - echo "${{ steps.upstream.outputs.version }}" > vendor/bentopdf-viewer/.upstream-version - echo "${{ steps.upstream.outputs.version }}" > vendor/bentopdf-pdfium/.upstream-version + echo "$SHA" > vendor/bentopdf-viewer/.upstream-version + echo "$SHA" > vendor/bentopdf-pdfium/.upstream-version + + - name: Upload update + if: steps.gate.outputs.run == 'true' + uses: actions/upload-artifact@v4 + with: + name: engine-update + include-hidden-files: true + retention-days: 1 + path: | + package.json + package-lock.json + vendor/bentopdf-pdfium/ + vendor/bentopdf-viewer/ + + pull-request: + runs-on: ubuntu-latest + needs: build + if: needs.build.outputs.run == 'true' + permissions: + contents: write + pull-requests: write + steps: + - name: Checkout repo + uses: actions/checkout@v4 + + - name: Download update + uses: actions/download-artifact@v4 + with: + name: engine-update + path: ${{ runner.temp }}/engine-update + + - name: Apply update + id: apply + env: + UPDATE: ${{ runner.temp }}/engine-update + run: | + set -euo pipefail + rm -rf vendor/bentopdf-pdfium vendor/bentopdf-viewer + mkdir -p vendor + cp -R "$UPDATE/vendor/bentopdf-pdfium" "$UPDATE/vendor/bentopdf-viewer" vendor/ + cp "$UPDATE/package.json" "$UPDATE/package-lock.json" . + { + echo 'checksums<> "$GITHUB_OUTPUT" - name: Create Pull Request - if: steps.gate.outputs.run == 'true' - uses: peter-evans/create-pull-request@v6 + uses: peter-evans/create-pull-request@c5a7806660adbe173f04e3e038b0ccdcd758773c # v6.1.0 with: - commit-message: 'build(deps): bump PDF engine and viewer to ${{ steps.upstream.outputs.version }}' - title: 'build(deps): bump PDF engine and viewer to ${{ steps.upstream.outputs.version }}' + commit-message: 'build(deps): bump PDF engine and viewer to ${{ needs.build.outputs.version }}' + title: 'build(deps): bump PDF engine and viewer to ${{ needs.build.outputs.version }}' body: | Both vendored packages built from one clone of the viewer fork at - `${{ steps.upstream.outputs.version }}`, so the text editor and the + `${{ needs.build.outputs.version }}`, so the text editor and the PDF editor run the same engine. - `bentopdf-pdfium` — the engine wasm, packed from the fork's published `src/vendor` @@ -194,5 +260,11 @@ jobs: The viewer loads the shared engine through `EmbedPDF.init({ wasmUrl })`. CI verified a single wasm on disk, typecheck, and the test suite. + + Built in a read-only job. Tarball checksums: + + ``` + ${{ steps.apply.outputs.checksums }} + ``` branch: chore/update-pdf-engine delete-branch: true diff --git a/.gitignore b/.gitignore index d48b56e0..659007ea 100644 --- a/.gitignore +++ b/.gitignore @@ -54,3 +54,10 @@ test # Sibling checkouts of the engine/viewer forks bentopdf-pdfium-viewer/ bentopdf-pymupdf-wasm/ + +/blog/*.html +!/blog/author-alam.html +/src/partials/tool-links-static.html +/security-headers-docs.conf +/dist-*.zip +/docs/.vitepress/cache/ diff --git a/.htaccess b/.htaccess index 81710c99..5c790594 100644 --- a/.htaccess +++ b/.htaccess @@ -12,7 +12,7 @@ Header always set Referrer-Policy "strict-origin-when-cross-origin" Header always set Permissions-Policy "geolocation=(), microphone=(), camera=()" Header always set Cross-Origin-Opener-Policy "same-origin" Header always set Cross-Origin-Embedder-Policy "require-corp" -Header always set Content-Security-Policy "default-src 'self'; script-src 'self' 'wasm-unsafe-eval' 'unsafe-eval' blob: https://cdn.jsdelivr.net; worker-src 'self' blob:; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; img-src 'self' data: blob: https:; font-src 'self' data: https://fonts.gstatic.com https://rawcdn.githack.com; connect-src 'self' blob: https://api.github.com https://fonts.gstatic.com https://cdn.jsdelivr.net https://bentopdf-cors-proxy.bentopdf.workers.dev https://rawcdn.githack.com; object-src 'none'; base-uri 'self'; frame-src 'self' blob:; frame-ancestors 'self'; form-action 'self'" +Header always set Content-Security-Policy "default-src 'self'; script-src 'self' 'wasm-unsafe-eval' 'unsafe-eval' blob: https://cdn.jsdelivr.net/npm/@bentopdf/pymupdf-wasm@0.11.16/ https://cdn.jsdelivr.net/npm/@bentopdf/gs-wasm@0.1.1/assets/ https://cdn.jsdelivr.net/npm/coherentpdf@2.5.5/dist/ https://cdn.jsdelivr.net/npm/tesseract.js@v7.0.0/dist/ https://cdn.jsdelivr.net/npm/tesseract.js-core@v7.0.0/; worker-src 'self' blob:; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; img-src 'self' data: blob: https:; font-src 'self' data: https://fonts.gstatic.com https://rawcdn.githack.com; connect-src 'self' blob: https://api.github.com https://fonts.gstatic.com https://cdn.jsdelivr.net https://bentopdf-cors-proxy.bentopdf.workers.dev https://rawcdn.githack.com; object-src 'none'; base-uri 'self'; frame-src 'self' blob:; frame-ancestors 'self'; form-action 'self'" # Block source maps from being served @@ -99,7 +99,7 @@ RewriteRule ^(.*)$ https://%{HTTP_HOST}%{REQUEST_URI} [L,R=301] # Remove trailing slash (except for language root directories) RewriteCond %{REQUEST_FILENAME} !-d -RewriteCond %{REQUEST_URI} !^/(de|es|zh|zh-TW|vi|it|id|tr|fr|pt)/$ +RewriteCond %{REQUEST_URI} !^/(ar|be|da|de|es|fr|id|it|ja|ko|nl|pl|pt|ru|sk|sv|tr|uk|vi|zh|zh-TW)/$ RewriteCond %{REQUEST_URI} (.+)/$ RewriteRule ^ %1 [R=301,L] @@ -117,11 +117,11 @@ RewriteRule ^en/(.+)$ /$1 [R=301,L] # Language prefix root (e.g., /de/ -> /de/index.html) RewriteCond %{DOCUMENT_ROOT}/$1/index.html -f -RewriteRule ^(de|es|zh|zh-TW|vi|it|id|tr|fr|pt)/?$ /$1/index.html [L] +RewriteRule ^(ar|be|da|de|es|fr|id|it|ja|ko|nl|pl|pt|ru|sk|sv|tr|uk|vi|zh|zh-TW)/?$ /$1/index.html [L] # Language prefix with path (e.g., /de/merge-pdf -> /de/merge-pdf.html) RewriteCond %{DOCUMENT_ROOT}/$1/$2.html -f -RewriteRule ^(de|es|zh|zh-TW|vi|it|id|tr|fr|pt)/([^/]+)/?$ /$1/$2.html [L] +RewriteRule ^(ar|be|da|de|es|fr|id|it|ja|ko|nl|pl|pt|ru|sk|sv|tr|uk|vi|zh|zh-TW)/([^/]+)/?$ /$1/$2.html [L] # ============================================ # 5.5. DOCS ROUTING (VitePress) diff --git a/.prettierignore b/.prettierignore index e70c3cd6..fb47a898 100644 --- a/.prettierignore +++ b/.prettierignore @@ -1,2 +1,7 @@ # Handlebars partials with template syntax inside HTML attributes src/partials/footer.html + +blog/posts/ + +public/pdfjs-viewer/ +public/pdfjs-annotation-viewer/ diff --git a/Dockerfile b/Dockerfile index 245e52a6..4551448e 100644 --- a/Dockerfile +++ b/Dockerfile @@ -3,7 +3,7 @@ ARG BASE_URL= # Build stage -FROM public.ecr.aws/docker/library/node:20-alpine AS builder +FROM mirror.gcr.io/library/node:26-alpine AS builder WORKDIR /app COPY package*.json ./ COPY vendor ./vendor diff --git a/Dockerfile.nonroot b/Dockerfile.nonroot index aa16f550..ebeb6f30 100644 --- a/Dockerfile.nonroot +++ b/Dockerfile.nonroot @@ -5,7 +5,7 @@ ARG BASE_URL= # Build stage (identical to main Dockerfile) -FROM public.ecr.aws/docker/library/node:20-alpine AS builder +FROM mirror.gcr.io/library/node:26-alpine AS builder WORKDIR /app COPY package*.json ./ COPY vendor ./vendor diff --git a/README.md b/README.md index 4909ea29..60796a56 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,13 @@

BentoPDF

+

Supported by

+ + Vercel OSS Program + +      - DigitalOcean Referral Badge + DigitalOcean Referral Badge

@@ -29,6 +34,7 @@ - [Convert to PDF](#convert-to-pdf) - [Convert from PDF](#convert-from-pdf) - [Secure & Optimize PDFs](#secure--optimize-pdfs) +- [Other Products](#-other-products) - [Translations](#-translations) - [Getting Started](#-getting-started) - [Prerequisites](#prerequisites) @@ -124,8 +130,7 @@ We're incredibly grateful to all our sponsors and supporters who help keep Bento [![Sponsor me on GitHub](https://img.shields.io/badge/Become%20a%20Sponsor-%E2%9D%A4-ff69b4?style=for-the-badge)](https://github.com/sponsors/alam00000) [![Buy me a Coffee](https://img.shields.io/badge/Buy%20me%20a%20Coffee-yellow?style=for-the-badge&logo=kofi)](https://ko-fi.com/alio01) - - +User avatar: User avatar: User avatar: Ethan ShollyUser avatar: Knud HollanderUser avatar: Mohannad HussainUser avatar: User avatar: Robert GonzalesUser avatar: MichiUser avatar: BerzanUser avatar: Thanyanit JongjitraganUser avatar: Kyle L. DavisUser avatar: User avatar: User avatar: Hari HaranUser avatar: ToadieUser avatar: --- @@ -175,6 +180,7 @@ BentoPDF offers a comprehensive suite of tools to handle all your PDF needs. | Tool Name | Description | | :------------------------ | :---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | **PDF Editor** | Annotate, highlight, redact, comment, add shapes/images, search, and view PDFs. | +| **Edit PDF Text** | Edit existing text directly in your PDF. Reuses the document's embedded fonts and keeps the rest of the page untouched. | | **Create Fillable Forms** | Create professional fillable PDF forms with text fields, checkboxes, dropdowns, radio buttons, signatures, and more. Fully compliant with PDF standards for compatibility with all PDF viewers. | | **PDF Form Filler** | Fill in forms directly in the browser. Also supports XFA forms. | | **Add Page Numbers** | Easily add page numbers with customizable formatting. | @@ -1219,16 +1225,20 @@ BentoPDF was originally built using **HTML**, **CSS**, and **vanilla JavaScript* --- +## 📦 Other Products + +| Product | Description | Links | +| :----------------- | :------------------------------------------------------------------------------------------------------------------------------------ | :----------------------------------------------------------------------------------------------------- | +| **Kura** | PDF standards and preflight engine. Converts to every PDF/A level, PDF/UA, PDF/X, PDF/E and PDF/VT, and can perform preflight checks. | [Website](https://kura.bentopdf.com) · [GitHub](https://github.com/alam00000/bentopdf-kura) | +| **Hyper Compress** | The best open source PDF compression engine. | [Website](https://hyper.bentopdf.com) · [GitHub](https://github.com/alam00000/bentopdf-hyper-compress) | + +--- + ## 🗺️ Roadmap ### Planned Features: - **HTML to PDF**: Convert HTML files or web pages into PDF documents. -- **Markdown to PDF**: Enhanced support for converting `.md` files to PDF. -- **Convert to PDF/A**: Convert PDFs to the PDF/A archival format. -- **Edit PDF Content**: Directly edit text and other content within your PDF. -- **PDF to Office**: Converts PDF files into editable Word, Excel, and PowerPoint formats. -- **Office to PDF**: Converts Word, Excel, and PowerPoint documents into optimized PDFs. Contributions and discussions on the roadmap are welcome! Join the conversation via [Discord](https://discord.gg/Bgq3Ay3f2w). diff --git a/STATIC-HOSTING.md b/STATIC-HOSTING.md index 434039db..44ec284f 100644 --- a/STATIC-HOSTING.md +++ b/STATIC-HOSTING.md @@ -62,6 +62,17 @@ When the build completes, you can find the website at `https://[your-github-user If/when you merge changes from the source BentoPDF repository, the build and deploy action will automatically be kicked off and the new version will be automatically deployed to GitHub Pages. +> **Note:** GitHub Pages cannot set custom response headers, so a GitHub Pages deployment is served without BentoPDF's Content-Security-Policy and other security headers. If you need them, use one of the other hosts below or put a proxy or CDN that can add headers in front of your Pages site. + +## Security headers + +BentoPDF ships a Content-Security-Policy and other security headers for each static host: + +- **Netlify and Cloudflare Pages** read `_headers` from the deployed folder. `npm run build` writes `dist/_headers` with `scripts/generate-security-headers.mjs`, so any `VITE_WASM_*`, `VITE_TESSERACT_*`, `VITE_CORS_PROXY_URL` or `VITE_OCR_FONT_BASE_URL` values you set at build time are allowed by the policy. If you add your own `public/_headers`, the build ships yours instead. The release `dist-{version}.zip` already contains it. +- **Vercel** uses the `headers` block in `vercel.json`. It holds the default policy, so if you point the `VITE_*` URLs at your own servers, update the Content-Security-Policy there to match the `dist/_headers` your build produces. +- **`npx serve`** reads `serve.json` from the folder it serves. To use the repository's `serve.json` with the build output, run `npx serve dist -c ../serve.json`. +- **GitHub Pages** cannot set headers (see the note above). + ## SEO and search engine indexing By default, every self-hosted BentoPDF build emits canonical URLs and an diff --git a/about.html b/about.html index cb1c1063..be49594a 100644 --- a/about.html +++ b/about.html @@ -5,7 +5,7 @@ - About BentoPDF - Privacy-First Free PDF Tools | Our Mission + About BentoPDF - Privacy-First Free PDF Tools | Our Story + @@ -77,220 +78,267 @@ - + {{> navbar }} -
-
-

- We believe PDF tools should be - fast, private, and free. -

-

- No compromises. -

-
- -
- -
-
- -

- Our Mission -

-

- To provide the most comprehensive PDF toolbox that respects your - privacy and never asks for payment. We believe essential document - tools should be accessible to everyone, everywhere, without - barriers. -

-
-
- -
-
-
- Our Core Philosophy +
+
+
+
+

+ Hi, I'm Alam. + I build BentoPDF. +

+
+ Alam + GitHub + LinkedIn + X +
+
+
+

+ BentoPDF started off as just a simple internal tool I used for + quickly merging and splitting PDFs. +

+

+ At some point, I decided to + share it on Reddit. Someone in the comments + asked if it was open source. I thought, “Why not?” So a couple of weeks later, I + put the code on GitHub. +

+

+ I posted it on + r/selfhosted + the next day, and that's when things really started to take off. +

+

+ I honestly never thought BentoPDF would grow into what it is + today. It started as a little tool I made for myself, and + somehow it turned into a much bigger project used by people all + over the world. +

+
+
+ +
+

+ I don't build BentoPDF alone anymore. More than 60 people have + contributed code, translations and fixes, and thousands more have + starred it, shared it, reported bugs and asked for features. Thank + you.

+

Enough about me. Here's more about BentoPDF.

-
-
-
-
- +
+
+ +
+
+
+

+ Our + Mission +

+
+ To provide the most comprehensive PDF toolbox that respects your + privacy and never asks for payment. We believe essential document + tools should be accessible to everyone, everywhere, without + barriers. +
+
+
+
+ +
+
+
+
+

+ PDF tools should be + fast, private, and free. +

+

+ No compromises. +

+

+ Every BentoPDF tool runs locally in your browser. Your files are + never uploaded, nobody else sees your documents, and nothing + tracks what you do. +

+
+
+
+
+ 0No Uploads +
+
+ 0No Signups +
+
+ $0Forever Free
-
+ -
-

- Why - BentoPDF? -

-
-
- +
+
+
-

- Built for Speed -

-

- No waiting for uploads or downloads to a server. By processing - files directly in your browser using modern web technologies - like WebAssembly, we offer unparalleled speed for all our tools. -

+

+ Why + BentoPDF? +

-
- -
-

- Completely Free -

-

- No trials, no subscriptions, no hidden fees, and no "premium" - features held hostage. We believe powerful PDF tools should be a - public utility, not a profit center. +

+
+ +

Built for Speed

+

+ There's no upload or download step. Files are processed right in + your browser using WebAssembly, so you're never waiting on a + server.

-
-
- -
-

+
+ +

Free to Use

+

+ Every tool is free, with no trials, no subscriptions and no + watermarks. Development is funded by commercial licenses. +

+
+
+ +

No Account Required

-

- Start using any tool immediately. We don't need your email, a - password, or any personal information. Your workflow should be - frictionless and anonymous. +

+ Open any tool and start right away. No email, no password, no + personal information.

-

-
- -
-

- Open Source Spirit +
+ +

+ Open Source

-

- Built with transparency in mind. We leverage incredible - open-source libraries like PDF-lib and PDF.js, and believe in - the community-driven effort to make powerful tools accessible to - everyone. +

+ The code is public on GitHub under AGPL-3.0, so you can check + exactly how your files are handled. It builds on open-source + projects like PDF.js, pdf-lib, qpdf and LibreOffice.

-
- -
-

- Ready to get started? -

-

- Join thousands of users who trust BentoPDF for their daily document - needs. Experience the difference that privacy and performance can - make. -

- - Explore All Tools - +
+
+
+

+ Ready to get started? +

+

+ Join thousands of users who trust BentoPDF for their daily + document needs. Experience the difference that privacy and + performance can make. +

+ +
+
@@ -299,6 +347,7 @@ + diff --git a/blog/author-alam.html b/blog/author-alam.html new file mode 100644 index 00000000..804e17aa --- /dev/null +++ b/blog/author-alam.html @@ -0,0 +1,149 @@ + + + + + + Alam, Creator of BentoPDF + + + + + + + + + + + + + + + + + + {{> navbar }} + +
+
+ Alam +
+

Alam

+

Creator and maintainer of BentoPDF

+
+
+ +

+ I’m Alam, and I build + BentoPDF, + a free, open-source PDF toolkit with 100+ tools that run entirely in + your browser. +

+

+ There’s no uploading your files to a server, no account to create, and + no tracking. Your documents stay on your device. The + source code + is public, so you can see how it works yourself. BentoPDF is + AGPL-licensed and self-hostable. +

+

+ I write every post on this blog myself. I’ll write about PDFs, how they + work, things I learn while building BentoPDF, and occasionally things + that go wrong. +

+

+ When I compare BentoPDF with another PDF tool, I’ll link to its + documentation so you can check what I’m saying. If there’s something + BentoPDF doesn’t do well, I’ll say that too. +

+

+ And if I get something wrong, please + open an issue. I’ll fix it and add a note to the post. +

+ +

+ You can find me on + GitHub, + LinkedIn + and + X. +

+ +

+ Read the blog +

+
+ + {{> footer }} + + + + + + + diff --git a/blog/posts/edit-pdf-without-uploading.md b/blog/posts/edit-pdf-without-uploading.md new file mode 100644 index 00000000..0f246fcd --- /dev/null +++ b/blog/posts/edit-pdf-without-uploading.md @@ -0,0 +1,134 @@ +--- +{ + "title": "How to Edit a PDF Without Uploading It Anywhere | BentoPDF Blog", + "h1": "How to edit a PDF without uploading it anywhere", + "ogTitle": "How to Edit a PDF Without Uploading It Anywhere", + "breadcrumb": "Edit a PDF without uploading", + "description": "Edit PDF text, fill forms, redact, and sign in your browser while the file stays on your device. Step by step, with the right tool for each job.", + "card": "Rewrite text, fill forms, redact properly, and sign, locally on your device.", + "date": "2026-09-03", + "cta": { + "heading": "Edit your PDF now", + "text": "Free, no signup. Your file stays on your device.", + "href": "/edit-pdf", + "label": "Open the PDF editor" + } +} +--- +You can edit a PDF in your browser without uploading the file to a server. + +Open [BentoPDF's PDF editor](/edit-pdf), pick your file, make your changes, and download the result. The PDF stays on your device. There's no account and no watermark. + +But “edit a PDF” can mean a few different things. You might want to change existing text, fill out a form, add a signature, redact something, or just highlight a paragraph. The right tool depends on what you're trying to do. + +## Why edit a PDF without uploading it? + +Think about the PDFs you normally edit. + +Contracts. Bank statements. Tax documents. Forms with your address or ID number. + +Those aren't necessarily files you want to upload to a random “free PDF editor.” + +With an on-device PDF editor, the file stays on your computer while you're working on it. + +You can even check this yourself. Open your browser's developer tools, go to the Network tab, and watch what happens while you edit. The PDF itself doesn't need to be sent to a server. + +## How to edit the actual text in a PDF + +If you want to change words that are already in the PDF, use [Edit PDF Text](/edit-pdf-text). + +1. Open the tool. +2. Drop in your PDF. +3. Click on the text you want to change. +4. Type your changes. +5. Download the edited PDF. + +BentoPDF loads the PDF in your browser and does the editing on your device. + +This is one of the harder things to do with PDFs because PDF text doesn't work quite like text in a Word document. The PDF is mostly concerned with putting things at particular positions on a page, so changing a sentence can mean figuring out how the surrounding text should move. + +It works well for ordinary documents, but PDFs with unusual embedded fonts or very complicated layouts can be trickier. I'd always open the downloaded PDF and check it. + +## How to annotate a PDF + +If you don't need to change the existing text, annotating a PDF is much simpler. + +The [PDF editor](/edit-pdf) lets you add highlights, comments, shapes, stamps, text boxes, and images. + +Open the PDF, add your annotations, and download the result. + +Again, the editing happens in your browser rather than on a remote server. + +## How to fill out a PDF form + +There are two common kinds of PDF forms. + +If the PDF has actual form fields, open it in [PDF Form Filler](/form-filler) and type into the fields. It handles standard AcroForm fields, and also XFA forms, which are the legacy XML-based format. XFA is worth calling out because Chrome and most other viewers show those files as blank pages, which is why some government forms tell you to open them in Adobe Reader. Firefox and BentoPDF's form filler render them properly. + +If it's a scanned or flattened form, there aren't any fields to click. In that case, you can put text boxes over the blank spaces in the [PDF editor](/edit-pdf) and type into them. If you'd rather have real fields, [Create PDF Form](/form-creator) can add them to the document first. + +This is especially useful for government and HR forms, which often contain personal information I'd rather not upload somewhere just to fill in a few boxes. + +If the PDF is a scan and you actually need to edit the scanned text itself, you'll need OCR first. [BentoPDF's OCR tool](/ocr-pdf) can recognize the text in the document while keeping the processing on your device. + +## How to redact a PDF properly + +This one is important because putting a black box over text isn't necessarily redaction. + +If you draw a rectangle over a name, the original name may still exist underneath it. It might still be selectable, searchable, or extractable. + +Proper redaction removes the underlying content. + +The [PDF editor](/edit-pdf) has redaction tools that permanently remove the underlying content rather than drawing over it. For documents where you also want to strip metadata or other hidden content, [Sanitize PDF](/sanitize-pdf) can help with that. + +There's also a privacy reason to do redaction locally: if you upload the original, unredacted PDF to a redaction service, that service has already received the information you're trying to hide. + +## How to sign a PDF without uploading it + +[Sign PDF](/sign-pdf) lets you add a drawn, typed, or image signature to a PDF. Place it on the page, resize it, and optionally flatten it so it becomes part of the page instead of an annotation someone can drag away later. + +The signature is added on your device, so you don't need to upload the document to a signing service just to place a signature on it. + +One thing to keep in mind: adding a signature image to a PDF is not the same thing as applying a cryptographic digital signature. If the recipient needs a signature that can actually be verified, use [Digital Signature](/digital-sign-pdf) instead. It signs the document with an X.509 certificate (PKCS#12 .pfx and .p12, or PEM), which proves who signed it and shows whether anything changed afterwards. You can also add a visible signature block on top of that. The certificate password and private key stay in your browser, same as the PDF. + +## Can you edit a PDF offline? + +Yes. + +There's an easy way to test it: load the editor, turn off Wi-Fi, and edit a PDF. + +The PDF processing code runs in your browser using WebAssembly. Once the application has loaded, the actual PDF processing doesn't need a server. + +This is also something you can inspect yourself in your browser's developer tools. The website is how you get the software; it doesn't have to be where your PDF gets processed. + +If you want to run the whole thing yourself, BentoPDF is [open source and self-hostable](https://github.com/alam00000/bentopdf). You can run your own instance instead of relying on ours. + +## What about scanned PDFs? + +A scanned PDF is basically a collection of images. + +If you want to change the words in it, an ordinary PDF text editor can't simply edit them because there isn't any text there yet. + +You'll need OCR (optical character recognition) to turn the pixels into text. + +You can either OCR the PDF first and then edit the resulting text, or, if you're just filling out a scanned form, put text boxes on top of the existing page. + +## Is there a file size limit? + +There isn't an imposed file-size limit. + +The practical limit is your device's available memory. Normal PDFs are usually fine, but a thousand-page scanned archive is a very different workload from a five-page contract. + +For very large PDFs, a desktop application may be a better choice. + +## Will editing a digitally signed PDF break the signature? + +Yes. + +Changing a digitally signed PDF will invalidate its existing signature. This isn't specific to BentoPDF; it's a consequence of changing a document that has already been signed. + +So the usual order is: + +**Edit first. Sign last.** + +If you need to make changes to a signed document, make sure you understand the signature requirements before doing so. diff --git a/chart/Chart.yaml b/chart/Chart.yaml index 5f9f397f..e2da8620 100644 --- a/chart/Chart.yaml +++ b/chart/Chart.yaml @@ -7,10 +7,10 @@ type: application # This is the chart version. This version number should be incremented each time you make changes # to the chart and its templates, including the app version. # Versions are expected to follow Semantic Versioning (https://semver.org/) -version: 1.0.7 +version: 1.0.9 # This is the version number of the application being deployed. This version number should be # incremented each time you make changes to the application. Versions are not expected to # follow Semantic Versioning. They should reflect the version the application is using. # It is recommended to use it with quotes. -appVersion: '2.8.8' +appVersion: '2.8.10' diff --git a/chart/templates/deployment.yaml b/chart/templates/deployment.yaml index cbe2ca74..ea957f10 100644 --- a/chart/templates/deployment.yaml +++ b/chart/templates/deployment.yaml @@ -65,6 +65,9 @@ spec: mountPath: /var/cache/nginx - name: nginx-run mountPath: /var/run + {{- if .Values.extraVolumeMounts }} + {{- toYaml .Values.extraVolumeMounts | nindent 12 }} + {{- end }} volumes: - name: nginx-tmp emptyDir: {} @@ -72,3 +75,6 @@ spec: emptyDir: {} - name: nginx-run emptyDir: {} + {{- if .Values.extraVolumes }} + {{- toYaml .Values.extraVolumes | nindent 8 }} + {{- end }} diff --git a/chart/values.yaml b/chart/values.yaml index ad627c60..b045236b 100644 --- a/chart/values.yaml +++ b/chart/values.yaml @@ -88,3 +88,7 @@ readinessProbe: httpGet: path: / port: http + +extraVolumes: [] + +extraVolumeMounts: [] diff --git a/cloudflare/WASM-PROXY.md b/cloudflare/WASM-PROXY.md index fa6963d8..3fdce5e9 100644 --- a/cloudflare/WASM-PROXY.md +++ b/cloudflare/WASM-PROXY.md @@ -83,7 +83,7 @@ npx wrangler deploy -c wasm-wrangler.toml ## Security Features - **Origin validation**: Only allows requests from configured origins -- **Rate limiting**: 100 requests/minute per IP (requires KV namespace) +- **Rate limiting**: 100 requests/minute per IP, per /64 for IPv6 (requires KV namespace; fails open on KV errors) - **File type restrictions**: Only WASM-related files (.js, .wasm, .data, etc.) - **Size limits**: Max 100MB per file - **Caching**: Reduces origin requests and improves performance diff --git a/cloudflare/cors-proxy-worker.js b/cloudflare/cors-proxy-worker.js index 6eb38ca7..fa1681ed 100644 --- a/cloudflare/cors-proxy-worker.js +++ b/cloudflare/cors-proxy-worker.js @@ -81,53 +81,134 @@ function isAllowedOrigin(origin) { return ALLOWED_ORIGINS.includes(origin); } +function parseIPv4(address) { + const match = /^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/.exec(address); + if (!match) return null; + const octets = match.slice(1).map(Number); + if (octets.some((octet) => octet > 255)) return null; + return octets.reduce((value, octet) => value * 256 + octet, 0); +} + +function parseIPv6(address) { + let text = address.toLowerCase(); + const embedded = /^(.*:)(\d+\.\d+\.\d+\.\d+)$/.exec(text); + if (embedded) { + const ipv4 = parseIPv4(embedded[2]); + if (ipv4 === null) return null; + text = `${embedded[1]}${Math.floor(ipv4 / 65536).toString(16)}:${(ipv4 % 65536).toString(16)}`; + } + const sections = text.split('::'); + if (sections.length > 2) return null; + const head = sections[0] ? sections[0].split(':') : []; + const tail = + sections.length === 2 && sections[1] ? sections[1].split(':') : []; + const missing = 8 - head.length - tail.length; + if (sections.length === 1 ? missing !== 0 : missing < 1) return null; + const groups = [...head, ...Array(missing).fill('0'), ...tail]; + if (!groups.every((group) => /^[0-9a-f]{1,4}$/.test(group))) return null; + return groups.map((group) => parseInt(group, 16)); +} + +const BLOCKED_IPV4_RANGES = [ + '0.0.0.0/8', + '10.0.0.0/8', + '100.64.0.0/10', + '127.0.0.0/8', + '169.254.0.0/16', + '172.16.0.0/12', + '192.0.0.0/24', + '192.0.2.0/24', + '192.168.0.0/16', + '198.18.0.0/15', + '198.51.100.0/24', + '203.0.113.0/24', + '224.0.0.0/4', + '240.0.0.0/4', +].map((cidr) => { + const [address, prefixLength] = cidr.split('/'); + return { base: parseIPv4(address), size: 2 ** (32 - Number(prefixLength)) }; +}); + +const BLOCKED_IPV6_RANGES = [ + '::/128', + '::1/128', + '::/96', + '::ffff:0:0/96', + '64:ff9b::/96', + '64:ff9b:1::/48', + '100::/64', + '2001::/32', + '2001:db8::/32', + '2002::/16', + 'fc00::/7', + 'fe80::/10', + 'fec0::/10', + 'ff00::/8', +].map((cidr) => { + const [address, prefixLength] = cidr.split('/'); + return { groups: parseIPv6(address), prefixLength: Number(prefixLength) }; +}); + +function isBlockedIPv4(value) { + return BLOCKED_IPV4_RANGES.some( + ({ base, size }) => value >= base && value < base + size + ); +} + +function isBlockedIPv6(groups) { + return BLOCKED_IPV6_RANGES.some((range) => + range.groups.every((group, index) => { + const bitsInGroup = Math.min( + 16, + Math.max(0, range.prefixLength - index * 16) + ); + const groupMask = (0xffff << (16 - bitsInGroup)) & 0xffff; + return (groups[index] & groupMask) === (group & groupMask); + }) + ); +} + +function isBlockedIp(address) { + const ipv4 = parseIPv4(address); + if (ipv4 !== null) return isBlockedIPv4(ipv4); + const ipv6Groups = parseIPv6(address); + if (ipv6Groups !== null) return isBlockedIPv6(ipv6Groups); + return true; +} + +function isIpLiteral(host) { + return /^[\d.]+$/.test(host) || host.includes(':'); +} + +function getRateLimitKey(ip) { + const ipv6Groups = ip.includes(':') ? parseIPv6(ip) : null; + if (!ipv6Groups) return ip; + return `${ipv6Groups + .slice(0, 4) + .map((group) => group.toString(16)) + .join(':')}::/64`; +} + function isPrivateOrReservedHost(hostname) { - if ( - /^10\./.test(hostname) || - /^172\.(1[6-9]|2[0-9]|3[0-1])\./.test(hostname) || - /^192\.168\./.test(hostname) || - /^169\.254\./.test(hostname) || // link-local (cloud metadata) - /^100\.(6[4-9]|[7-9]\d|1[0-1]\d|12[0-7])\./.test(hostname) || // CGNAT - /^127\./.test(hostname) || - /^0\./.test(hostname) - ) { + const host = hostname + .toLowerCase() + .replace(/^\[|\]$/g, '') + .replace(/\.$/, ''); + + if (['localhost', 'localhost.localdomain'].includes(host)) { return true; } - if (/^\d+$/.test(hostname)) { - return true; - } - - const lower = hostname.toLowerCase().replace(/^\[|\]$/g, ''); - if ( - lower === '::1' || - lower.startsWith('::ffff:') || - lower.startsWith('fe80') || - lower.startsWith('fc') || - lower.startsWith('fd') || - lower.startsWith('ff') - ) { - return true; - } - - const blockedNames = [ - 'localhost', - 'localhost.localdomain', - '0.0.0.0', - '[::1]', - ]; - if (blockedNames.includes(hostname.toLowerCase())) { - return true; - } - - return false; + return isIpLiteral(host) && isBlockedIp(host); } async function hostnameResolvesToPrivate(hostname) { - const clean = hostname.replace(/^\[|\]$/g, ''); - if (/^\d+\.\d+\.\d+\.\d+$/.test(clean) || clean.includes(':')) { - return isPrivateOrReservedHost(clean); - } + const clean = hostname + .toLowerCase() + .replace(/^\[|\]$/g, '') + .replace(/\.$/, ''); + if (isPrivateOrReservedHost(clean)) return true; + if (isIpLiteral(clean)) return false; const ips = []; for (const type of ['A', 'AAAA']) { try { @@ -138,14 +219,16 @@ async function hostnameResolvesToPrivate(hostname) { if (!res.ok) return true; const data = await res.json(); for (const ans of data.Answer || []) { - if (ans && ans.data) ips.push(String(ans.data).replace(/\.$/, '')); + if (ans && (ans.type === 1 || ans.type === 28) && ans.data) { + ips.push(String(ans.data)); + } } } catch { return true; } } if (ips.length === 0) return true; - return ips.some((ip) => isPrivateOrReservedHost(ip)); + return ips.some((ip) => isBlockedIp(ip)); } function isValidCertificateUrl(urlString) { @@ -372,49 +455,56 @@ export default { } const clientIP = request.headers.get('CF-Connecting-IP') || 'unknown'; - const rateLimitKey = `ratelimit:${clientIP}`; + const rateLimitKey = `ratelimit:${getRateLimitKey(clientIP)}`; const now = Date.now(); if (env.RATE_LIMIT_KV) { - const rateLimitData = await env.RATE_LIMIT_KV.get(rateLimitKey, { - type: 'json', - }); - const requests = rateLimitData?.requests || []; + try { + const rateLimitData = await env.RATE_LIMIT_KV.get(rateLimitKey, { + type: 'json', + }); + const requests = rateLimitData?.requests || []; - const recentRequests = requests.filter( - (t) => now - t < RATE_LIMIT_WINDOW_MS - ); + const recentRequests = requests.filter( + (t) => now - t < RATE_LIMIT_WINDOW_MS + ); - if (recentRequests.length >= RATE_LIMIT_MAX_REQUESTS) { - return new Response( - JSON.stringify({ - error: 'Rate limit exceeded', - message: `Maximum ${RATE_LIMIT_MAX_REQUESTS} requests per minute. Please try again later.`, - retryAfter: Math.ceil( - (recentRequests[0] + RATE_LIMIT_WINDOW_MS - now) / 1000 - ), - }), - { - status: 429, - headers: { - ...corsHeaders(origin), - 'Content-Type': 'application/json', - 'Retry-After': Math.ceil( + if (recentRequests.length >= RATE_LIMIT_MAX_REQUESTS) { + return new Response( + JSON.stringify({ + error: 'Rate limit exceeded', + message: `Maximum ${RATE_LIMIT_MAX_REQUESTS} requests per minute. Please try again later.`, + retryAfter: Math.ceil( (recentRequests[0] + RATE_LIMIT_WINDOW_MS - now) / 1000 - ).toString(), - }, + ), + }), + { + status: 429, + headers: { + ...corsHeaders(origin), + 'Content-Type': 'application/json', + 'Retry-After': Math.ceil( + (recentRequests[0] + RATE_LIMIT_WINDOW_MS - now) / 1000 + ).toString(), + }, + } + ); + } + + recentRequests.push(now); + await env.RATE_LIMIT_KV.put( + rateLimitKey, + JSON.stringify({ requests: recentRequests }), + { + expirationTtl: 120, } ); + } catch (error) { + console.error( + '[CORS Proxy] Rate limit KV error, allowing request:', + error + ); } - - recentRequests.push(now); - await env.RATE_LIMIT_KV.put( - rateLimitKey, - JSON.stringify({ requests: recentRequests }), - { - expirationTtl: 120, - } - ); } else { console.warn( '[CORS Proxy] RATE_LIMIT_KV not configured — rate limiting is disabled' diff --git a/cloudflare/wasm-proxy-worker.js b/cloudflare/wasm-proxy-worker.js index a61346e2..fdfe6449 100644 --- a/cloudflare/wasm-proxy-worker.js +++ b/cloudflare/wasm-proxy-worker.js @@ -39,9 +39,38 @@ const ALLOWED_EXTENSIONS = [ '.mem', '.asm.js', '.worker.js', - '.html', + '.whl', ]; +const SAFE_SUBPATH = /^\/[A-Za-z0-9._/-]*$/; + +function isSafeSubpath(subpath) { + return ( + SAFE_SUBPATH.test(subpath) && + !subpath.split('/').some((segment) => segment === '.' || segment === '..') + ); +} + +function getRateLimitKey(ip) { + if (!ip.includes(':') || ip.includes('.')) return ip; + const sections = ip.toLowerCase().split('::'); + if (sections.length > 2) return ip; + const leadingGroups = sections[0] ? sections[0].split(':') : []; + const trailingGroups = sections[1] ? sections[1].split(':') : []; + const missing = 8 - leadingGroups.length - trailingGroups.length; + if (sections.length === 1 ? missing !== 0 : missing < 1) return ip; + const groups = [ + ...leadingGroups, + ...Array(missing).fill('0'), + ...trailingGroups, + ]; + if (!groups.every((group) => /^[0-9a-f]{1,4}$/.test(group))) return ip; + return `${groups + .slice(0, 4) + .map((group) => parseInt(group, 16).toString(16)) + .join(':')}::/64`; +} + function isAllowedOrigin(origin) { if (!origin) return true; // Allow no-origin requests (e.g., direct browser navigation) return ALLOWED_ORIGINS.some((allowed) => @@ -66,6 +95,7 @@ function corsHeaders(origin) { 'Access-Control-Expose-Headers': 'Content-Length, Content-Range, Content-Type', 'Access-Control-Max-Age': '86400', + 'X-Content-Type-Options': 'nosniff', }; } @@ -89,7 +119,6 @@ function getContentType(pathname) { '.so': 'application/octet-stream', '.zip': 'application/zip', '.mem': 'application/octet-stream', - '.html': 'text/html', }; return contentTypes[ext] || 'application/octet-stream'; } @@ -115,7 +144,34 @@ async function proxyRequest(request, env, sourceBaseUrl, subpath, origin) { ? sourceBaseUrl.slice(0, -1) : sourceBaseUrl; const normalizedPath = subpath.startsWith('/') ? subpath : `/${subpath}`; - const targetUrl = `${normalizedBase}${normalizedPath}`; + + let targetUrl = null; + if (isSafeSubpath(normalizedPath)) { + try { + const candidate = new URL(`${normalizedBase}${normalizedPath}`).href; + if (candidate.startsWith(new URL(`${normalizedBase}/`).href)) { + targetUrl = candidate; + } + } catch { + targetUrl = null; + } + } + + if (!targetUrl) { + return new Response( + JSON.stringify({ + error: 'Invalid path', + message: 'The requested path is not allowed.', + }), + { + status: 400, + headers: { + ...corsHeaders(origin), + 'Content-Type': 'application/json', + }, + } + ); + } if (!isAllowedFile(normalizedPath)) { return new Response( @@ -151,8 +207,6 @@ async function proxyRequest(request, env, sourceBaseUrl, subpath, origin) { JSON.stringify({ error: 'Failed to fetch resource', status: response.status, - statusText: response.statusText, - targetUrl: targetUrl, }), { status: response.status, @@ -204,14 +258,14 @@ async function proxyRequest(request, env, sourceBaseUrl, subpath, origin) { 'Content-Type': getContentType(normalizedPath), 'Content-Length': bodyData.byteLength.toString(), 'Cache-Control': `public, max-age=${CACHE_TTL_SECONDS}`, - 'X-Proxied-From': new URL(targetUrl).hostname, }, }); } catch (error) { + console.error('[WASM Proxy] Fetch error:', error); return new Response( JSON.stringify({ error: 'Proxy error', - message: error.message, + message: 'Failed to fetch the requested resource', }), { status: 500, @@ -260,42 +314,49 @@ export default { if (env.RATE_LIMIT_KV) { const clientIP = request.headers.get('CF-Connecting-IP') || 'unknown'; - const rateLimitKey = `wasm-ratelimit:${clientIP}`; + const rateLimitKey = `wasm-ratelimit:${getRateLimitKey(clientIP)}`; const now = Date.now(); - const rateLimitData = await env.RATE_LIMIT_KV.get(rateLimitKey, { - type: 'json', - }); - const requests = rateLimitData?.requests || []; - const recentRequests = requests.filter( - (t) => now - t < RATE_LIMIT_WINDOW_MS - ); + try { + const rateLimitData = await env.RATE_LIMIT_KV.get(rateLimitKey, { + type: 'json', + }); + const requests = rateLimitData?.requests || []; + const recentRequests = requests.filter( + (t) => now - t < RATE_LIMIT_WINDOW_MS + ); - if (recentRequests.length >= RATE_LIMIT_MAX_REQUESTS) { - return new Response( - JSON.stringify({ - error: 'Rate limit exceeded', - message: `Maximum ${RATE_LIMIT_MAX_REQUESTS} requests per minute.`, - }), + if (recentRequests.length >= RATE_LIMIT_MAX_REQUESTS) { + return new Response( + JSON.stringify({ + error: 'Rate limit exceeded', + message: `Maximum ${RATE_LIMIT_MAX_REQUESTS} requests per minute.`, + }), + { + status: 429, + headers: { + ...corsHeaders(origin), + 'Content-Type': 'application/json', + 'Retry-After': '60', + }, + } + ); + } + + recentRequests.push(now); + await env.RATE_LIMIT_KV.put( + rateLimitKey, + JSON.stringify({ requests: recentRequests }), { - status: 429, - headers: { - ...corsHeaders(origin), - 'Content-Type': 'application/json', - 'Retry-After': '60', - }, + expirationTtl: 120, } ); + } catch (error) { + console.error( + '[WASM Proxy] Rate limit KV error, allowing request:', + error + ); } - - recentRequests.push(now); - await env.RATE_LIMIT_KV.put( - rateLimitKey, - JSON.stringify({ requests: recentRequests }), - { - expirationTtl: 120, - } - ); } if (pathname.startsWith('/pymupdf/')) { diff --git a/contact.html b/contact.html index ba5b35d2..39d15715 100644 --- a/contact.html +++ b/contact.html @@ -49,6 +49,7 @@ + @@ -75,38 +76,78 @@ - + {{> navbar }} -
-
-

- Get in Touch -

-

- We'd love to hear from you. Whether you have a question, feedback, or - a feature request, please don't hesitate to reach out. -

+ {{> footer }} @@ -114,6 +155,7 @@ + + + + + + + + diff --git a/index.html b/index.html index 52177c01..0f33e5a4 100644 --- a/index.html +++ b/index.html @@ -12,7 +12,7 @@ /> + - + {{> navbar }} -
+
@@ -319,8 +280,6 @@
-
-

Why choose @@ -328,132 +287,24 @@ >BentoPDF?

-
-
-
- -

- No Signup -

-
-

- Start instantly, no accounts or emails. -

+
+
+ 100+Tools
-
-
- -

- No Uploads -

-
-

- 100% client-side, your files never leave your device. -

+
+ 0No Uploads
-
-
- -

- Forever Free -

-
-

- All tools, no trials, no paywalls. -

+
+ ∞Unlimited Use
-
-
- -

- No Limits -

-
-

- Use as much as you want, no hidden caps. -

-
-
-
- -

- Batch Processing -

-
-

- Handle unlimited PDFs in one go. -

-
-
-
- -

- Lightning Fast -

-
-

- Process PDFs instantly, without waiting or delays. -

+
+ $0Forever Free
-
-

Get Started with @@ -500,7 +351,9 @@
+ > + {{> tool-links-static }} +

-
-
@@ -782,9 +633,209 @@ -
+
+

+ What Our + Users + Say +

+
+
+
+ My dad is a very frequent user at 70+ yo and I never ever received + a call or a question or a complaint about this app, unlike many + others 😂 it just works and never gives headaches of any kind! +
+
+ u/Cyberpunk627 + Reddit +
+
+
+
+ I work in indigenous healthcare. … I need total confidentiality of + my data, as you can imagine, and I need to split pdfs. Bento is + the first time this trivial task has become possible + confidentially. +
+
+ ballantynedewolf + GitHub +
+
+
+
+ Really awesome tool. I’m in the legal field and have been stuck + with Adobe because they do have pretty robust redaction tools, + although it is super clunky and resource intensive. Bento is + quickly gaining market share in my legal stack. +
+
+ u/sewersurfin + Reddit +
+
+
+
+ BentoPDF provides tools that I have ended up using on a daily + basis, and I can do it secure in the knowledge that my documents + are remaining private. It just works. +
+
+ Fubarpie + Trustpilot +
+
+
+
+ Huge fan of Bento PDF. We don't have a decent PDF suite at work so + most of my colleagues turn to online tools that are likely to + collect your data. I selfhosted Bento for my team and their + reactions are very positive. +
+
+ Schlemmy + Lemmy +
+
+
+
+ I've used it before for a job application! I needed to send them + sensitive data. Tysm! Great intuitive UI, does what it says, and + it's fast. 5/5 +
+
+ Lemminary + Lemmy +
+
+
+
+ BentoPDF is fantastic, I never knew something like this existed. … + I fired Bento up and was done in under a minute. +
+
+ suicidaleggroll + Lemmy +
+
+
+
+ A great open source software tool that has helped me deal with + PDF's on numerous occasions. Support and use this project. It + needs the love and attention to grow even bigger. +
+
+ Jeffrey Jansma + Trustpilot +
+
+
+
+ Fantastic project. The workflow builder literally just saved me + half a day of my life so huge thanks. +
+
+ nomandera + GitHub +
+
+
+
+ Thank you so much for this. We just started using it at our + school. +
+
+ Limeade3425 + Lemmy +
+
+
+
+ My first reaction after trying Bento was "this is magic, how does + this work?" +
+
+ darcmage + Lemmy +
+
+
+
+ BentoPDF has covered 100% of my PDF needs and represents + impressive software engineering indeed! +
+
+ PrestonMcAfee + GitHub +
+
+
+
-
+

@@ -794,334 +845,182 @@ >

-
- -
-

- Yes, absolutely. All tools on BentoPDF are 100% free to use, with - no file limits, no sign-ups, and no watermarks. We believe - everyone deserves access to simple, powerful PDF tools without a - paywall. -

-
-
- -
- -
-

- Your files are as secure as possible because they **never leave - your computer**. All processing happens directly in your web - browser (client-side). We never upload your files to a server, so - you maintain complete privacy and control over your documents. -

-
-
- -
- -
-

- Yes! Since BentoPDF runs entirely in your browser, it works on any - operating system with a modern web browser, including Windows, - macOS, Linux, iOS, and Android. -

-
-
- - -
- -
-

- Yes. BentoPDF is fully GDPR compliant. Since all file processing - happens locally in your browser and we never collect or transmit - your files to any server, we have no access to your data. This - ensures you are always in control of your documents. -

-
-
- - -
- -
-

- No. We never store, track, or log your files. Everything you do on - BentoPDF happens in your browser memory and disappears once you - close the page. There are no uploads, no history logs, and no - servers involved. -

-
-
- - -
- -
-

- Most PDF tools upload your files to a server for processing. - BentoPDF never does that. We use secure, modern web technology to - process your files directly in your browser. This means faster - performance, stronger privacy, and complete peace of mind. -

-
-
- - -
- -
-

- By running entirely inside your browser, BentoPDF ensures that - your files never leave your device. This eliminates the risks of - server hacks, data breaches, or unauthorized access. Your files - remain yours—always. -

-
-
- - -
- -
-

- No. BentoPDF does not track you at all. We do not use cookies, - analytics, or any tracking scripts, not even on the live website - at bentopdf.com. We never know who you are or what you do with the - tools. -

-
-
-
- -
- -
-

- What Our - Users - Say -

-
-
-
-
-

Sarah L.

-
- ★★★★★ -
+
+
+
+ +
+

+ Yes, absolutely. All tools on BentoPDF are 100% free to use, + with no file limits, no sign-ups, and no watermarks. We + believe everyone deserves access to simple, powerful PDF tools + without a paywall. +

-

- "This is the tool I've been searching for! It's fast, free, and I - love that my confidential documents never get uploaded to some - random server. A lifesaver for my freelance work." -

-
-
-
-
-

Mark Chen

-
- ★★★★★ -
+ +
+ +
+

+ Your files are as secure as possible because they **never + leave your computer**. All processing happens directly in your + web browser (client-side). We never upload your files to a + server, so you maintain complete privacy and control over your + documents. +

-

- "Finally, a PDF editor that just works. No ads, no sign-ups, no - nonsense. The merge tool is surprisingly powerful. I've already - bookmarked it on all my devices." -

-
-
-
-
-

Anonymous User A-35Z

-
- ★☆☆☆☆ -
+ +
+ +
+

+ Yes! Since BentoPDF runs entirely in your browser, it works on + any operating system with a modern web browser, including + Windows, macOS, Linux, iOS, and Android. +

-

- "Terrible. It won't let me upload my files to the cloud. How is my - Big Data Tech Overlord supposed to know I signed a permission slip - for my kid's field trip? Useless for my data profile." -

-
-
-
-
-

Dr. Brickson

-
- ★★★★★ -
+ + +
+ +
+

+ Yes. BentoPDF is fully GDPR compliant. Since all file + processing happens locally in your browser and we never + collect or transmit your files to any server, we have no + access to your data. This ensures you are always in control of + your documents. +

-

- "As a researcher, data privacy is paramount. BentoPDF's - client-side processing model is exactly what my institution - recommends. It's robust, reliable, and secure. A fantastic - resource." -

-
-
-
-
-

AdTracker Pro

-
- ★☆☆☆☆ -
+ + +
+ +
+

+ No. We never store, track, or log your files. Everything you + do on BentoPDF happens in your browser memory and disappears + once you close the page. There are no uploads, no history + logs, and no servers involved. +

-

- "This website is broken. My ad blocker says it hasn't blocked a - single tracker. How am I supposed to know if a product is good if - it's not following me around the internet for a week? 1 star." -

-
-
-
-
-

Raj P.

-
- ★★★★★ -
+ + +
+ +
+

+ Most PDF tools upload your files to a server for processing. + BentoPDF never does that. We use secure, modern web technology + to process your files directly in your browser. This means + faster performance, stronger privacy, and complete peace of + mind. +

+
+
+ + +
+ +
+

+ By running entirely inside your browser, BentoPDF ensures that + your files never leave your device. This eliminates the risks + of server hacks, data breaches, or unauthorized access. Your + files remain yours, always. +

+
+
+ + +
+ +
+

+ No. BentoPDF does not track you at all. We do not use cookies, + analytics, or any tracking scripts, not even on the live + website at bentopdf.com. We never know who you are or what you + do with the tools. +

-

- "Simple, elegant, and powerful. I needed to merge 50 reports, and - it handled it instantly without crashing my browser. This is what - a web tool should be. Highly recommended." -

diff --git a/kura.html b/kura.html new file mode 100644 index 00000000..8700973f --- /dev/null +++ b/kura.html @@ -0,0 +1,683 @@ + + + + + + + Kura - PDF/A, PDF/UA & PDF/X Conversion Engine | BentoPDF + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + {{> navbar }} + +
+
+
+
+
+

+ Meet + Kura +

+

+ The PDF standards and preflight engine. Convert any PDF to every + PDF/A level, PDF/UA, PDF/X, PDF/E and PDF/VT, then preflight the + result against 396 bundled profiles to confirm it conforms. +

+

+ Ships as a CLI, npm package, browser WebAssembly build, Docker + image and C library. +

+ +
+
+ +
+
+ 25conformance targets +
+
+ 396bundled preflight profiles +
+
+ 0false findings in conformance testing +
+
+ 5form factors, one engine +
+
+
+
+ +
+
+
+
+ +

+ Open source, self-hosting and internal use stay free. + Kura is licensed under the AGPL-3.0. If your project is + AGPL-compatible, or you run Kura as-is for yourself, you never + need a commercial license. Commercial licenses are only for + shipping Kura inside closed-source software. +

+
+
+
+
+ +
+
+
+
+

+ Commercial + Licenses +

+

+ Per product, billed annually. Every tier includes all updates + and new releases while active. +

+
+
+ +
+
+

Solo

+

+ One developer, under $100k annual revenue +

+
$99/ year
+
    +
  • One closed-source product
  • +
  • All updates while active
  • +
  • Email support
  • +
+ Get Solo +
+
+

Indie

+

Companies under $1M annual revenue

+
$199/ year
+
    +
  • One closed-source product
  • +
  • Unlimited developers
  • +
  • All updates while active
  • +
  • Email support
  • +
+ Get Indie +
+
+

Business

+

Companies at $1M annual revenue or more

+
$499/ year
+
    +
  • One closed-source product
  • +
  • Unlimited developers
  • +
  • All updates while active
  • +
  • Priority email support
  • +
+ Get Business +
+
+

OEM

+

+ Redistribution, reselling as an SDK, or offering Kura as a + service +

+
+ from$1,999/ year +
+
    +
  • Redistribution rights
  • +
  • Terms shaped to your product
  • +
  • Priority support
  • +
+ Contact us +
+
+
+
+ +
+
+
+
+

+ Commercial license + details +

+
+
+
+
+
+

+ + Delivery & licensing model +

+
    +
  • + + No license key is required or provided. + Kura intentionally does not use license keys, to + streamline deployment and reduce operational + friction. +
  • +
  • + + Upon purchase you receive a ZIP archive containing the + complete Kura source code. +
  • +
  • + + Because Kura is open source, every release and every + update can always be downloaded from our + GitHub releases page. +
  • +
  • + + The source code provided is identical to the code + published in our public open-source repository. +
  • +
  • + + No refunds. Under no + circumstances shall a refund be made after purchase. +
  • +
+

+ GitHub repository: + github.com/alam00000/bentopdf-kura +

+
+ +
+

+ + Invoicing +

+
    +
  • + + We use Polar for + payments, which + automatically sends invoices + via email after purchase. +
  • +
  • + + Polar handles + VAT invoices for + businesses in applicable regions. +
  • +
  • + + For VAT invoices, select + "I'm purchasing as a business" + during checkout and enter your billing address and Tax/VAT + number. +
  • +
+
+
+
+
+
+ +
+
+
+
+

+ What each tier + covers +

+

+ Every tier lets you ship Kura inside one closed-source product. + The tiers differ by who you are, how many developers you have, + and whether Kura is passed on to anyone else. +

+
+
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
SoloIndieBusinessOEM
Who it's forOne developer, under $100k annual revenueCompanies under $1M annual revenueCompanies at $1M annual revenue or more + Anyone redistributing, reselling or hosting the engine +
Annual price$99$199$499from $1,999
Developers coveredOne developerUnlimitedUnlimitedDiscussed with you
Closed-source products coveredOneOneOneTerms shaped to your product
Updates while active
SupportEmailEmailPriority emailPriority
+ Redistribute inside someone else's product +
Resell as an SDK
Offer Kura as a hosted service
+
+

+ Redistribution, reselling and hosting are what separate OEM from + the rest. If Kura only ships inside your own closed-source + product, the choice between Solo, Indie and Business is purely a + question of your size. +

+
+
+
+ +
+
+
+
+

+ License + Terms +

+
+
+
+
+ +

Per product

+

+ A license covers one shipped product. A second product needs a + second license, or ask about covering your whole catalogue at + OEM. +

+
+
+ +

Updates included

+

+ Every release published while your license is active is yours. + If you let it lapse, you keep a perpetual right to the last + version you received. +

+
+
+ +

No AGPL obligations

+

+ The commercial license replaces the AGPL for your product. You + ship closed source, with no requirement to publish your changes + or your application. +

+
+
+ +

Clean provenance

+

+ Kura is written and owned by BentoPDF, and every third-party + component it links, including qpdf, PDFium and FreeType, is + permissively licensed. No copyleft surprises in your supply + chain. +

+
+
+
+
+ +
+
+
+
+

+ Frequently Asked + Questions +

+
+
+
+
+
+ +
+

+ Only if you ship Kura inside closed-source software. + Open-source projects under an AGPL-compatible license, + self-hosting, and internal use are all free under the + AGPL-3.0. +

+
+
+
+ +
+

+ No. Kura is a separate project with its own license. The + BentoPDF app license does not grant any rights to Kura, and + a Kura license does not cover BentoPDF. +

+
+
+
+ +
+

+ You move up to the matching tier at your next renewal. + Nothing retroactive. Growing past a threshold mid-term never + invalidates your current license. +

+
+
+
+
+
+
+ +
+
+
+

Not sure which tier fits?

+

+ Tell us what you're building and we'll tell you what it costs. No + calls required. +

+ +
+
+
+
+ + {{> footer }} + + + + + + + + diff --git a/licensing.html b/licensing.html index 9027743f..5ce4d2dd 100644 --- a/licensing.html +++ b/licensing.html @@ -49,6 +49,7 @@ + @@ -75,1122 +76,1172 @@ - + {{> navbar }} -
-
-

- Licensing for - BentoPDF -

-

- Choose the license that fits your needs. -

+
+
+
+
+
+

+ Licensing for + BentoPDF +

+

+ Choose the license that fits your needs. +

+
+
+
-
-
- - - LIFETIME LICENSE - +
+
+
+
+ + + LIFETIME LICENSE + - -

- Commercial License -

+ +

+ Commercial License +

- -
+ +
- -
-
-
- -
- Proprietary Use -
-
-
- -
- Unlimited Devices -
-
-
- -
- Enterprise Support -
-
-
- -
- Unlimited Users -
-
-
- -
- Early Access Features -
-
-
- -
- Lifetime Updates -
-
-
- -
- No AGPL Obligations -
-
-
- -
- Flexible Terms -
-
- - -
- - -
-
- $99 - $79 - one-time -
- - Get License Now - - - - - -
-
-
- -
- -
-
- -
-
- -

Open Source

-
-

- BentoPDF is licensed under the - GNU Affero General Public License v3.0 (AGPL-3.0). You are free to use, modify, and distribute BentoPDF in your - open-source projects. -

-
-
- -
-

Free to Use

-

- Use BentoPDF for free in open-source projects where you make - your source code publicly available. -

+ +
+
+
+ +
+ Proprietary Use +
+
+
+ +
+ Unlimited Devices +
+
+
+ +
+ Enterprise Support +
+
+
+ +
+ Unlimited Users +
+
+
+ +
+ Early Access Features +
+
+
+ +
+ Lifetime Updates +
+
+
+ +
+ No AGPL Obligations +
+
+
+ +
+ Flexible Terms
-
- -
-

- Modify & Distribute -

-

- Modify the source code and distribute your version, as long - as you comply with AGPL-3.0 requirements. -

+ + +
+ + +
+
+ $99 + $79 + one-time
-
-
- -
-

Share Alike

-

- Any modifications or derivative works must also be licensed - under AGPL-3.0 and made publicly available. -

-
-
-
- - View on GitHub - -
- - -
-
- -

Commercial

-
-

- If you want to use BentoPDF in a - closed-source or proprietary application - without sharing your source code, you must purchase a commercial - license. - The commercial license covers BentoPDF only and grants no - rights to - Kura - or - Hyper Compress. -

-
-
- -
-

Proprietary Use

-

- Use BentoPDF in closed-source applications without the - obligation to disclose your source code. -

-
-
-
- -
-

- Enterprise Support -

-

- Get priority support, custom features, and assistance with - integration. -

-
-
-
- -
-

- Early Access to New Features -

-

- Commercial license holders get early access to new features - and updates. -

-
-
-
- -
-

Flexible Terms

-

- Licensing terms tailored to your business needs and project - requirements. -

-
-
-
- - - Get Commercial License - - - - - -

- 💡 Custom requests and development are available for separate - charges. - Contact us - for details. -

-
-
-
- -
- -
-

- Do I Need a Commercial License? -

-

- Use this quick reference chart to determine if your use case requires - a commercial license. -

- -
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
- Use Case - - License Required -
- Personal use (not hosting as a service) - - - AGPL (Free) - -
- Open-source project with publicly available source code - - - AGPL (Free) - -
- Public website where you share your full modified source code - under AGPL - - - AGPL (Free) - -
- Educational/research project with publicly shared code - - - AGPL (Free) - -
- SaaS or web application (closed-source) - - - - Commercial - -
- Internal company tool/dashboard (source not shared with users) - - - - Commercial - -
- Commercial product (keeping source code private) - - - - Commercial - -
- Network service where source code is not accessible to users - - - - Commercial - -
- Any app where you want to keep modifications private - - - - Commercial - -
-
- -
- - Get Commercial License - $79 - - - - - -

- Still not sure? - Contact us - to discuss your specific use case. -

-
-
- -
- -
-

- AGPL Requirements -

-
-

- When using BentoPDF under AGPL-3.0, you must comply with the - following requirements: -

-
-
- -
-

- Producer Line & Copyright -

-

- You should mention our open-source and include AGPL license - details in the PDF metadata. You should also retain the - producer line in all PDFs created or modified using our - open-source. -

-
-
-
- -
-

- Code Modifications -

-

- Any modifications to our open-source must be disclosed under - the AGPL. This includes sharing the modified code with any - users interacting with it. -

-
-
-
- -
-

- AGPL-Compliant Environments Only -

-

- Usage of our open-source Community Edition must be limited to - AGPL-compliant environments, where all AGPL requirements are - fully respected. -

-
-
-
- -
-

- Source Code Disclosure -

-

- You cannot deploy our open-source as part of a server-based - application or service without disclosing your own - application's full source code under AGPL to any users - interacting with it. -

-
-
-
-
-
- -
- -
-

- What is AGPL-Compliant? -

-
- -
-
- -

AGPL-Compliant

-
-
    -
  • - - Open-source projects where you share your full source code - publicly -
  • -
  • - - Personal use where you're not distributing or hosting it as a - service -
  • -
  • - - Publicly hosted tools where you provide the complete source - code (including modifications) under AGPL -
  • -
  • - - Educational or research projects that make all code publicly - available -
  • -
-
- - -
-
- -

NOT AGPL-Compliant

-
-
    -
  • - - Proprietary/closed-source SaaS applications -
  • -
  • - - Internal company tools where source code is not shared with - users -
  • -
  • - - Commercial products that don't disclose the full source - code -
  • -
  • - - Network services that don't provide source code access to - users -
  • -
  • - - Any application where you want to keep your source code - private -
  • -
-

- ⚠️ These use cases require a commercial license. -

-
-
-
- -
- -
-

- Licensing FAQs -

-
-
- -
-

- If you're using BentoPDF on a public website and have not - modified the source code, you do not need a commercial license. - However, if you modify BentoPDF in any way and make it available - to users over a network, the AGPLv3 requires you to provide your - modified source code to those users. If you want to keep your - modifications closed-source, or if you are integrating BentoPDF - into a proprietary or commercial product, you will need a - commercial license. Contact us to discuss your specific use - case. -

-
-
- -
- -
-

- If your internal tool is closed-source and not shared publicly, - you will need a commercial license. The AGPL-3.0 license - requires that you make the source code of any network-accessible - service using BentoPDF available to its users. If you cannot or - do not want to do that, a commercial license is the right - choice. -

-
-
- -
- -
-

- If you modify BentoPDF and deploy it as a network service (e.g., - a web application), you must make your modified source code - available under AGPL-3.0. If you do not want to share your - modifications, you must obtain a commercial license. -

-
-
- -
- -
-

- BentoPDF is available under a lifetime, one-time purchase - commercial license. You can purchase it directly here: Buy Commercial License.

- If you have specific requirements or want a custom licensing - arrangement, feel free to - contact us - with details about your use case, company size, and deployment - needs. We're happy to work with you to find the best fit. -

-
-
- -
- -
-

- No. The BentoPDF commercial license covers BentoPDF - only. - It grants no rights to - Kura - or - Hyper Compress. Those are separate projects with their own licenses, and any - commercial use of them must be licensed separately. -

+ Get License Now + + + + + +
-
+
+
+
+
+ +
+
+ +

Open Source

+
+

+ BentoPDF is licensed under the + GNU Affero General Public License v3.0 (AGPL-3.0). You are free to use, modify, and distribute BentoPDF in + your open-source projects. +

+
+
+ +
+

Free to Use

+

+ Use BentoPDF for free in open-source projects where you + make your source code publicly available. +

+
+
+
+ +
+

+ Modify & Distribute +

+

+ Modify the source code and distribute your version, as + long as you comply with AGPL-3.0 requirements. +

+
+
+
+ +
+

Share Alike

+

+ Any modifications or derivative works must also be + licensed under AGPL-3.0 and made publicly available. +

+
+
+
+ + View on GitHub + +
-
-

- Commercial License Details -

- -
-
-

- - Delivery & Licensing Model -

- -

- GitHub Repository: + Get Commercial License + + + + + +

+ 💡 Custom requests and development are available for separate + charges. + Contact us + for details. +

+
+
+
+
+
+ +
+
+
+

+ Do I Need a Commercial License? +

+

+ Use this quick reference chart to determine if your use case + requires a commercial license. +

+ +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ Use Case + + License Required +
+ Personal use (not hosting as a service) + + + AGPL (Free) + +
+ Open-source project with publicly available source code + + + AGPL (Free) + +
+ Public website where you share your full modified source + code under AGPL + + + AGPL (Free) + +
+ Educational/research project with publicly shared code + + + AGPL (Free) + +
+ SaaS or web application (closed-source) + + + + Commercial + +
+ Internal company tool/dashboard (source not shared with + users) + + + + Commercial + +
+ Commercial product (keeping source code private) + + + + Commercial + +
+ Network service where source code is not accessible to + users + + + + Commercial + +
+ Any app where you want to keep modifications private + + + + Commercial + +
+
+ + - -
-

- - Invoicing -

-
    -
  • - - We use Polar for - payments, which - automatically sends invoices - via email after purchase. -
  • -
  • - - Polar handles - VAT invoices for - businesses in applicable regions. + + + +

    + Still not sure? + Contact us -

  • -
  • - - For VAT invoices, select - "I'm purchasing as a business" - during checkout and enter your billing address and Tax/VAT - number. -
  • -
-
- -
-

- - AGPL Components - Not Bundled -

-

- BentoPDF - does not bundle AGPL-licensed - processing libraries. The following components must be configured - separately via - Advanced Settings if you wish - to use their features: -

-
    -
  • - PyMuPDF (AGPL-3.0) -
  • -
  • - Ghostscript (AGPL-3.0) -
  • -
  • - CoherentPDF / CPDF (AGPL-3.0) -
  • -
-

- To enable features powered by these libraries: -

-
    -
  • - 1. - Navigate to - Advanced Settings in - BentoPDF -
  • -
  • - 2. - Configure the URL for each WASM module you need -
  • -
  • - 3. - You can host your own files, use a - WASM proxy, or use any compatible CDN -
  • -
-

- - The commercial license covers - BentoPDF's own code only. It - does not bypass the AGPL licensing of these components. Users must - comply with the AGPL v3 terms for these components. -

+ to discuss your specific use case. +

+
-
+
+
+
+

+ AGPL Requirements +

+
+

+ When using BentoPDF under AGPL-3.0, you must comply with the + following requirements: +

+
+
+ +
+

+ Producer Line & Copyright +

+

+ You should mention our open-source and include AGPL + license details in the PDF metadata. You should also + retain the producer line in all PDFs created or modified + using our open-source. +

+
+
+
+ +
+

+ Code Modifications +

+

+ Any modifications to our open-source must be disclosed + under the AGPL. This includes sharing the modified code + with any users interacting with it. +

+
+
+
+ +
+

+ AGPL-Compliant Environments Only +

+

+ Usage of our open-source Community Edition must be limited + to AGPL-compliant environments, where all AGPL + requirements are fully respected. +

+
+
+
+ +
+

+ Source Code Disclosure +

+

+ You cannot deploy our open-source as part of a + server-based application or service without disclosing + your own application's full source code under AGPL to any + users interacting with it. +

+
+
+
+
+
+
+
-
-

- Still have questions? -

-

- We're here to help. Reach out to discuss your licensing needs. -

- - Contact Us - +
+
+
+

+ What is AGPL-Compliant? +

+
+ +
+
+ +

AGPL-Compliant

+
+
    +
  • + + Open-source projects where you share your full source + code publicly +
  • +
  • + + Personal use where you're not distributing or hosting it + as a service +
  • +
  • + + Publicly hosted tools where you provide the complete + source code (including modifications) under AGPL +
  • +
  • + + Educational or research projects that make all code + publicly available +
  • +
+
+ + +
+
+ +

+ NOT AGPL-Compliant +

+
+
    +
  • + + Proprietary/closed-source SaaS applications +
  • +
  • + + Internal company tools where source code is not shared + with users +
  • +
  • + + Commercial products that don't disclose the full source + code +
  • +
  • + + Network services that don't provide source code access to + users +
  • +
  • + + Any application where you want to keep your source code + private +
  • +
+

+ ⚠️ These use cases require a commercial license. +

+
+
+
+
+
+ +
+
+
+
+

+ Licensing + FAQs +

+
+
+
+
+
+ +
+

+ If you're using BentoPDF on a public website and have not + modified the source code, you do not need a commercial + license. However, if you modify BentoPDF in any way and make + it available to users over a network, the AGPLv3 requires + you to provide your modified source code to those users. If + you want to keep your modifications closed-source, or if you + are integrating BentoPDF into a proprietary or commercial + product, you will need a commercial license. Contact us to + discuss your specific use case. +

+
+
+ +
+ +
+

+ If your internal tool is closed-source and not shared + publicly, you will need a commercial license. The AGPL-3.0 + license requires that you make the source code of any + network-accessible service using BentoPDF available to its + users. If you cannot or do not want to do that, a commercial + license is the right choice. +

+
+
+ +
+ +
+

+ If you modify BentoPDF and deploy it as a network service + (e.g., a web application), you must make your modified + source code available under AGPL-3.0. If you do not want to + share your modifications, you must obtain a commercial + license. +

+
+
+ +
+ +
+

+ BentoPDF is available under a lifetime, one-time purchase + commercial license. You can purchase it directly here: + Buy Commercial License.

+ If you have specific requirements or want a custom licensing + arrangement, feel free to + contact us + with details about your use case, company size, and + deployment needs. We're happy to work with you to find the + best fit. +

+
+
+ +
+ +
+

+ No. The BentoPDF commercial license covers BentoPDF + only. + It grants no rights to + Kura + or + Hyper Compress. Those are separate projects with their own licenses, and + any commercial use of them must be licensed separately. +

+
+
+
+
+
+
+ +
+
+
+

+ Commercial License Details +

+ +
+
+

+ + Delivery & Licensing Model +

+
    +
  • + + No license key is required or provided. + BentoPDF intentionally does not use license keys to + streamline deployment and reduce operational friction for + users. +
  • +
  • + + Upon purchase, users receive a ZIP archive containing the + complete BentoPDF source code and binaries. +
  • +
  • + + Because BentoPDF is open source, every release and every + update can always be downloaded from our + GitHub releases page. +
  • +
  • + + The source code provided is identical to the code + published in our public open-source repository. +
  • +
  • + + No refunds. Under no + circumstances shall a refund be made after purchase. +
  • +
+

+ GitHub Repository: + github.com/alam00000/bentopdf +

+
+ +
+

+ + Invoicing +

+
    +
  • + + We use Polar for + payments, which + automatically sends invoices + via email after purchase. +
  • +
  • + + Polar handles + VAT invoices for + businesses in applicable regions. +
  • +
  • + + For VAT invoices, select + "I'm purchasing as a business" + during checkout and enter your billing address and Tax/VAT + number. +
  • +
+
+ +
+

+ + AGPL Components - Not Bundled +

+

+ BentoPDF + does not bundle + AGPL-licensed processing libraries. The following components + must be configured separately via + Advanced Settings if you + wish to use their features: +

+
    +
  • + PyMuPDF (AGPL-3.0) +
  • +
  • + Ghostscript (AGPL-3.0) +
  • +
  • + CoherentPDF / CPDF (AGPL-3.0) +
  • +
+

+ To enable features powered by these libraries: +

+
    +
  • + 1. + Navigate to + Advanced Settings in + BentoPDF +
  • +
  • + 2. + Configure the URL for each WASM module you need +
  • +
  • + 3. + You can host your own files, use a + WASM proxy, or use any compatible CDN +
  • +
+

+ + The commercial license covers + BentoPDF's own code only. + It does not bypass the AGPL licensing of these components. + Users must comply with the AGPL v3 terms for these components. +

+
+
+
+
+
+ +
+
+
+

Still have questions?

+

+ We're here to help. Reach out to discuss your licensing needs. +

+ +
+
@@ -1198,6 +1249,7 @@ + `; +} + +function renderFaqs(faqs) { + if (!faqs || faqs.length === 0) return ''; + const items = faqs + .map( + (f) => `
+ ${escapeHtml(f.q)} +

+ ${renderInline(f.a)} +

+
` + ) + .join('\n'); + return ` +

FAQ

+
+${items} +
`; +} + +function renderCta(cta) { + if (!cta) return ''; + return ` +
+

${escapeHtml(cta.heading)}

+

${escapeHtml(cta.text)}

+ ${escapeHtml(cta.label)} +
`; +} + +function renderPost({ slug, meta, body }) { + const url = `${SITE_URL}/blog/${slug}`; + const updated = meta.updated || meta.date; + const bodyHtml = styleBody(marked.parse(body)); + const faqEntries = + meta.faqs && meta.faqs.length > 0 ? meta.faqs : questionSections(body); + + return ` + + + + + ${escapeHtml(meta.title)} + + + + + + + + + + + + + + + + + + {{> navbar }} + +
+ + +

+ ${escapeHtml(meta.h1)} +

+ +
+ ${AUTHOR.name} +
+ ${AUTHOR.name}, + builds BentoPDF · + +
+
+ +${bodyHtml} +${renderCta(meta.cta)} +${renderFaqs(meta.faqs)} +
+ + {{> footer }} + + + + + + ${faqSchema(faqEntries)} + + +`; +} + +function renderIndex(posts) { + const cards = posts + .map( + ({ slug, meta }) => ` +

+ ${escapeHtml(meta.h1)} +

+

+ ${escapeHtml(meta.card)} +

+ ${formatDate(meta.date)} +
` + ) + .join('\n\n'); + + return ` + + + + + The BentoPDF Blog | Privacy-First PDF Guides + + + + + + + + + + + + + + + + + + {{> navbar }} + +
+

The BentoPDF Blog

+

+ I'm Alam. I build + BentoPDF and write about PDFs, how they work, and the weird things I + run into along the way. +

+
+ +
+
+${cards} +
+
+ + {{> footer }} + + + + + + +`; +} + +function generate() { + if (!fs.existsSync(POSTS_DIR)) { + console.log('generate-blog: no blog/posts directory, skipping'); + return; + } + const files = fs + .readdirSync(POSTS_DIR) + .filter((f) => f.endsWith('.md')) + .sort(); + const posts = files.map(parsePost); + posts.sort((a, b) => (a.meta.date < b.meta.date ? 1 : -1)); + + const keep = new Set(['index.html', 'author-alam.html']); + for (const post of posts) { + fs.writeFileSync( + path.join(BLOG_DIR, `${post.slug}.html`), + renderPost(post) + ); + keep.add(`${post.slug}.html`); + } + fs.writeFileSync(path.join(BLOG_DIR, 'index.html'), renderIndex(posts)); + + for (const entry of fs.readdirSync(BLOG_DIR)) { + if (entry.endsWith('.html') && !keep.has(entry)) { + fs.rmSync(path.join(BLOG_DIR, entry)); + console.log(`generate-blog: removed stale ${entry}`); + } + } + + console.log( + `generate-blog: ${posts.length} posts + index generated from blog/posts/` + ); +} + +generate(); diff --git a/scripts/generate-i18n-pages.mjs b/scripts/generate-i18n-pages.mjs index 134738c2..1f4cb2d0 100644 --- a/scripts/generate-i18n-pages.mjs +++ b/scripts/generate-i18n-pages.mjs @@ -2,6 +2,7 @@ import fs from 'fs'; import path from 'path'; import { JSDOM } from 'jsdom'; import { fileURLToPath } from 'url'; +import { serializeJsonLd } from './json-ld.mjs'; const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); @@ -59,7 +60,47 @@ function buildUrl(langPrefix, pagePath) { if (BASE_PATH && BASE_PATH !== '') parts.push(BASE_PATH.replace(/^\//, '')); if (langPrefix) parts.push(langPrefix); if (pagePath) parts.push(pagePath.replace(/^\//, '')); - return parts.filter(Boolean).join('/').replace(/\/+$/, '') || SITE_URL; + const url = parts.filter(Boolean).join('/').replace(/\/+$/, '') || SITE_URL; + return langPrefix && !pagePath ? `${url}/` : url; +} + +function resolveTranslation(langResources, key) { + let namespace = 'common'; + let keyPath = key; + const colonIndex = key.indexOf(':'); + if (colonIndex !== -1) { + namespace = key.slice(0, colonIndex); + keyPath = key.slice(colonIndex + 1); + } + let node = langResources[namespace]; + for (const segment of keyPath.split('.')) { + if (node == null || typeof node !== 'object') return null; + node = node[segment]; + } + return typeof node === 'string' && node ? node : null; +} + +function applyServerTranslations(document, langResources) { + document.querySelectorAll('[data-i18n]').forEach((element) => { + const key = element.getAttribute('data-i18n'); + if (!key) return; + const translation = resolveTranslation(langResources, key); + if (translation) element.textContent = translation; + }); + + document.querySelectorAll('[data-i18n-placeholder]').forEach((element) => { + const key = element.getAttribute('data-i18n-placeholder'); + if (!key) return; + const translation = resolveTranslation(langResources, key); + if (translation) element.setAttribute('placeholder', translation); + }); + + document.querySelectorAll('[data-i18n-title]').forEach((element) => { + const key = element.getAttribute('data-i18n-title'); + if (!key) return; + const translation = resolveTranslation(langResources, key); + if (translation) element.setAttribute('title', translation); + }); } const ORGANIZATION_LD_MARKER = 'data-bentopdf-organization'; @@ -93,7 +134,7 @@ function injectOrganizationLd(document) { const script = document.createElement('script'); script.setAttribute('type', 'application/ld+json'); script.setAttribute(ORGANIZATION_LD_MARKER, ''); - script.textContent = JSON.stringify(data, null, 2); + script.textContent = serializeJsonLd(data); document.body.appendChild(script); } @@ -105,10 +146,24 @@ function buildLocalHomeHref(lang) { return `${BASE_PATH}${langSegment}`.replace(/\/+/g, '/'); } +function removeStaticBreadcrumbLd(document) { + for (const script of document.querySelectorAll( + `script[type="application/ld+json"]:not([${BREADCRUMB_MARKER}])` + )) { + try { + const parsed = JSON.parse(script.textContent || ''); + if (parsed && parsed['@type'] === 'BreadcrumbList') script.remove(); + } catch { + continue; + } + } +} + function injectToolBreadcrumb(document, lang, toolName, toolUrl) { const h1 = document.querySelector('h1[data-i18n^="tools:"]'); if (!h1) return; if (document.querySelector(`[${BREADCRUMB_MARKER}]`)) return; + removeStaticBreadcrumbLd(document); const homeUrl = buildUrl(lang === 'en' ? '' : lang, ''); @@ -160,7 +215,7 @@ function injectToolBreadcrumb(document, lang, toolName, toolUrl) { const script = document.createElement('script'); script.setAttribute('type', 'application/ld+json'); script.setAttribute(BREADCRUMB_MARKER, ''); - script.textContent = JSON.stringify(ld, null, 2); + script.textContent = serializeJsonLd(ld); document.body.appendChild(script); } @@ -171,18 +226,31 @@ function resolveToolName(translationKey, langTools) { return enEntry && enEntry.name ? enEntry.name : null; } +function translationKeyForFile(filenameNoExt) { + return KEY_MAPPING[filenameNoExt] || toCamelCase(filenameNoExt); +} + +function translatedLangsForFile(filenameNoExt, translations) { + const translationKey = translationKeyForFile(filenameNoExt); + return languages.filter( + (l) => + l === 'en' || + filenameNoExt === 'index' || + Boolean(translations[l] && translations[l].tools[translationKey]) + ); +} + function processFileForLanguage( originalContent, file, lang, translations, - langDir + langDir, + translatedLangs ) { const filenameNoExt = file.replace('.html', ''); - let translationKey = toCamelCase(filenameNoExt); - if (KEY_MAPPING[filenameNoExt]) { - translationKey = KEY_MAPPING[filenameNoExt]; - } + const translationKey = translationKeyForFile(filenameNoExt); + const isTranslated = translatedLangs.includes(lang); const { tools } = translations[lang]; const dom = new JSDOM(originalContent); @@ -191,6 +259,8 @@ function processFileForLanguage( document.documentElement.lang = lang; document.documentElement.dir = ['ar', 'fa'].includes(lang) ? 'rtl' : 'ltr'; + applyServerTranslations(document, translations[lang]); + let title = null; let description = null; @@ -232,22 +302,25 @@ function processFileForLanguage( const pagePath = filenameNoExt === 'index' ? '' : filenameNoExt; - languages.forEach((l) => { - const link = document.createElement('link'); - link.rel = 'alternate'; - link.hreflang = l; - link.href = buildUrl(l === 'en' ? '' : l, pagePath); - document.head.appendChild(link); - }); + if (isTranslated) { + translatedLangs.forEach((l) => { + const link = document.createElement('link'); + link.rel = 'alternate'; + link.hreflang = l; + link.href = buildUrl(l === 'en' ? '' : l, pagePath); + document.head.appendChild(link); + }); - const defaultLink = document.createElement('link'); - defaultLink.rel = 'alternate'; - defaultLink.hreflang = 'x-default'; - defaultLink.href = buildUrl('', pagePath); - document.head.appendChild(defaultLink); + const defaultLink = document.createElement('link'); + defaultLink.rel = 'alternate'; + defaultLink.hreflang = 'x-default'; + defaultLink.href = buildUrl('', pagePath); + document.head.appendChild(defaultLink); + } + const englishUrl = buildUrl('', pagePath); const localizedUrl = buildUrl(lang, pagePath); - const canonicalUrl = buildUrl('', pagePath); + const canonicalUrl = isTranslated ? localizedUrl : englishUrl; let canonical = document.querySelector('link[rel="canonical"]'); if (!canonical) { canonical = document.createElement('link'); @@ -257,9 +330,9 @@ function processFileForLanguage( canonical.href = canonicalUrl; const ogUrl = document.querySelector('meta[property="og:url"]'); - if (ogUrl) ogUrl.content = localizedUrl; + if (ogUrl) ogUrl.content = canonicalUrl; const twitterUrl = document.querySelector('meta[name="twitter:url"]'); - if (twitterUrl) twitterUrl.content = localizedUrl; + if (twitterUrl) twitterUrl.content = canonicalUrl; injectOrganizationLd(document); @@ -288,6 +361,8 @@ function processFileForLanguage( if (href.startsWith('/assets/') || href.includes('/assets/')) return; + if (href === '/blog' || href.startsWith('/blog/')) return; + const langPrefixRegex = new RegExp( `^(${BASE_PATH})?/(${languages.join('|')})(/|$)` ); @@ -313,7 +388,7 @@ function processFileForLanguage( fs.writeFileSync(path.join(langDir, file), result); } -function updateEnglishFile(filePath, originalContent) { +function updateEnglishFile(filePath, originalContent, translatedLangs) { const filenameNoExt = path.basename(filePath, '.html'); const dom = new JSDOM(originalContent); const document = dom.window.document; @@ -325,7 +400,7 @@ function updateEnglishFile(filePath, originalContent) { const pagePath = filenameNoExt === 'index' ? '' : filenameNoExt; const canonicalUrl = buildUrl('', pagePath); - languages.forEach((l) => { + translatedLangs.forEach((l) => { const link = document.createElement('link'); link.rel = 'alternate'; link.hreflang = l; @@ -402,6 +477,10 @@ async function generateI18nPages() { for (const file of htmlFiles) { const filePath = path.join(DIST_DIR, file); const originalContent = fs.readFileSync(filePath, 'utf-8'); + const translatedLangs = translatedLangsForFile( + file.replace('.html', ''), + translations + ); for (const lang of languages) { if (lang === 'en') continue; @@ -413,7 +492,8 @@ async function generateI18nPages() { file, lang, translations, - langDir + langDir, + translatedLangs ); processed++; @@ -425,7 +505,7 @@ async function generateI18nPages() { await new Promise((resolve) => setImmediate(resolve)); } - updateEnglishFile(filePath, originalContent); + updateEnglishFile(filePath, originalContent, translatedLangs); } console.log('✅ i18n pages generated successfully!'); diff --git a/scripts/generate-security-headers.mjs b/scripts/generate-security-headers.mjs index dffc184c..f7b68dfe 100644 --- a/scripts/generate-security-headers.mjs +++ b/scripts/generate-security-headers.mjs @@ -1,6 +1,7 @@ #!/usr/bin/env node -import { writeFileSync } from 'node:fs'; -import { join, dirname } from 'node:path'; +import { existsSync, writeFileSync } from 'node:fs'; +import { createRequire } from 'node:module'; +import { join, dirname, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; const repoRoot = join(dirname(fileURLToPath(import.meta.url)), '..'); @@ -16,103 +17,189 @@ function originOf(urlStr) { } } +const JSDELIVR_HOST = 'cdn.jsdelivr.net'; + +export const DOCS_INLINE_SCRIPT_HASHES = [ + 'sha256-DQUgNM9X0iH2019NkUxeBvnBoEoRKkHBc/I0iLqPNPA=', + 'sha256-La1r0VSk0Po4KFI0duEKhmPu+u0I416JW3oONqtdf4M=', +]; + +function toScriptSource(url) { + const origin = originOf(url); + if (!origin) return null; + const { host, pathname } = new URL(url); + if (host !== JSDELIVR_HOST) return origin; + const directory = /\.m?js$/.test(pathname) + ? pathname.replace(/[^/]*$/, '') + : pathname.replace(/\/?$/, '/'); + return `${origin}${directory.replace(/;/g, '%3B').replace(/,/g, '%2C')}`; +} + function uniq(values) { return Array.from(new Set(values.filter(Boolean))); } -const DEFAULT_WASM_ORIGINS = { - pymupdf: 'https://cdn.jsdelivr.net', - gs: 'https://cdn.jsdelivr.net', - cpdf: 'https://cdn.jsdelivr.net', +const DEFAULT_WASM_URLS = { + pymupdf: 'https://cdn.jsdelivr.net/npm/@bentopdf/pymupdf-wasm@0.11.16/', + gs: 'https://cdn.jsdelivr.net/npm/@bentopdf/gs-wasm@0.1.1/assets/', + cpdf: 'https://cdn.jsdelivr.net/npm/coherentpdf@2.5.5/dist/', }; + +const tesseractPackage = createRequire(import.meta.url)( + 'tesseract.js/package.json' +); +const DEFAULT_TESSERACT_WORKER_URL = `https://cdn.jsdelivr.net/npm/tesseract.js@v${tesseractPackage.version}/dist/worker.min.js`; +const DEFAULT_TESSERACT_CORE_URL = `https://cdn.jsdelivr.net/npm/tesseract.js-core@v${tesseractPackage.dependencies['tesseract.js-core'].substring(1)}/`; + const DEFAULT_CORS_PROXY_ORIGIN = 'https://bentopdf-cors-proxy.bentopdf.workers.dev'; const DEFAULT_OCR_FONT_CDN_ORIGIN = 'https://rawcdn.githack.com'; -const wasmOrigins = [ - originOf(process.env.VITE_WASM_PYMUPDF_URL) || DEFAULT_WASM_ORIGINS.pymupdf, - originOf(process.env.VITE_WASM_GS_URL) || DEFAULT_WASM_ORIGINS.gs, - originOf(process.env.VITE_WASM_CPDF_URL) || DEFAULT_WASM_ORIGINS.cpdf, +const COMMON_HEADERS = [ + ['X-Frame-Options', 'SAMEORIGIN'], + ['X-Content-Type-Options', 'nosniff'], + ['X-XSS-Protection', '1; mode=block'], + ['Referrer-Policy', 'strict-origin-when-cross-origin'], + [ + 'Permissions-Policy', + 'geolocation=(), camera=(), microphone=(), payment=(), usb=(), interest-cohort=()', + ], + ['Cross-Origin-Opener-Policy', 'same-origin'], + ['Cross-Origin-Embedder-Policy', 'credentialless'], + ['Cross-Origin-Resource-Policy', 'cross-origin'], ]; -const tesseractOrigins = uniq([ - originOf(process.env.VITE_TESSERACT_WORKER_URL), - originOf(process.env.VITE_TESSERACT_CORE_URL), - originOf(process.env.VITE_TESSERACT_LANG_URL), -]); +function urlOrDefault(value, fallback) { + return originOf(value) ? value : fallback; +} -const corsProxyOrigin = - originOf(process.env.VITE_CORS_PROXY_URL) || DEFAULT_CORS_PROXY_ORIGIN; +export function buildSecurityHeaders(env = process.env) { + const wasmUrls = [ + urlOrDefault(env.VITE_WASM_PYMUPDF_URL, DEFAULT_WASM_URLS.pymupdf), + urlOrDefault(env.VITE_WASM_GS_URL, DEFAULT_WASM_URLS.gs), + urlOrDefault(env.VITE_WASM_CPDF_URL, DEFAULT_WASM_URLS.cpdf), + ]; + const wasmOrigins = wasmUrls.map(originOf); -const ocrFontOrigin = - originOf(process.env.VITE_OCR_FONT_BASE_URL) || DEFAULT_OCR_FONT_CDN_ORIGIN; + const tesseractOrigins = uniq([ + originOf(env.VITE_TESSERACT_WORKER_URL), + originOf(env.VITE_TESSERACT_CORE_URL), + originOf(env.VITE_TESSERACT_LANG_URL), + ]); -const scriptOrigins = uniq([...wasmOrigins, ...tesseractOrigins]); -const connectOrigins = uniq([ - ...wasmOrigins, - ...tesseractOrigins, - corsProxyOrigin, - ocrFontOrigin, -]); -const fontOrigins = uniq([ocrFontOrigin].filter(Boolean)); + const corsProxyOrigin = + originOf(env.VITE_CORS_PROXY_URL) || DEFAULT_CORS_PROXY_ORIGIN; -const githubStarsDisabled = - process.env.DISABLE_GITHUB_STARS === 'true' || - process.env.SIMPLE_MODE === 'true'; -const githubApiSource = githubStarsDisabled ? '' : ' https://api.github.com'; + const ocrFontOrigin = + originOf(env.VITE_OCR_FONT_BASE_URL) || DEFAULT_OCR_FONT_CDN_ORIGIN; -const directives = [ - `default-src 'self'`, - `script-src 'self' 'wasm-unsafe-eval' 'unsafe-eval' blob: ${scriptOrigins.join(' ')}`.trim(), - `worker-src 'self' blob:`, - `style-src 'self' 'unsafe-inline' https://fonts.googleapis.com`, - `img-src 'self' data: blob: https:`, - `font-src 'self' data: https://fonts.gstatic.com ${fontOrigins.join(' ')}`.trim(), - `connect-src 'self' blob:${githubApiSource} https://fonts.gstatic.com ${connectOrigins.join(' ')}`.trim(), - `object-src 'none'`, - `base-uri 'self'`, - `frame-src 'self' blob:`, - `frame-ancestors 'self'`, - `form-action 'self'`, -]; + const scriptSources = uniq([ + ...wasmUrls.map(toScriptSource), + toScriptSource( + urlOrDefault(env.VITE_TESSERACT_WORKER_URL, DEFAULT_TESSERACT_WORKER_URL) + ), + toScriptSource( + urlOrDefault(env.VITE_TESSERACT_CORE_URL, DEFAULT_TESSERACT_CORE_URL) + ), + ]); + const connectOrigins = uniq([ + ...wasmOrigins, + ...tesseractOrigins, + corsProxyOrigin, + ocrFontOrigin, + ]); + const fontOrigins = uniq([ocrFontOrigin]); -const docsDirectives = [ - `default-src 'self'`, - `script-src 'self' 'unsafe-inline' ${scriptOrigins.join(' ')}`.trim(), - `style-src 'self' 'unsafe-inline' https://fonts.googleapis.com`, - `img-src 'self' data: blob: https:`, - `font-src 'self' data: https://fonts.gstatic.com ${fontOrigins.join(' ')}`.trim(), - `connect-src 'self'${githubApiSource} https://fonts.gstatic.com ${connectOrigins.join(' ')}`.trim(), - `object-src 'none'`, - `base-uri 'self'`, - `frame-ancestors 'self'`, - `form-action 'self'`, -]; + const githubStarsDisabled = + env.DISABLE_GITHUB_STARS === 'true' || env.SIMPLE_MODE === 'true'; + const githubApiSource = githubStarsDisabled ? '' : ' https://api.github.com'; -const csp = directives.join('; '); -const docsCsp = docsDirectives.join('; '); + const directives = [ + `default-src 'self'`, + `script-src 'self' 'wasm-unsafe-eval' 'unsafe-eval' blob: ${scriptSources.join(' ')}`.trim(), + `worker-src 'self' blob:`, + `style-src 'self' 'unsafe-inline' https://fonts.googleapis.com`, + `img-src 'self' data: blob: https:`, + `font-src 'self' data: https://fonts.gstatic.com ${fontOrigins.join(' ')}`.trim(), + `connect-src 'self' blob:${githubApiSource} https://fonts.gstatic.com ${connectOrigins.join(' ')}`.trim(), + `object-src 'none'`, + `base-uri 'self'`, + `frame-src 'self' blob:`, + `frame-ancestors 'self'`, + `form-action 'self'`, + ]; -const commonHeaders = `add_header X-Frame-Options "SAMEORIGIN" always; -add_header X-Content-Type-Options "nosniff" always; -add_header X-XSS-Protection "1; mode=block" always; -add_header Referrer-Policy "strict-origin-when-cross-origin" always; -add_header Permissions-Policy "geolocation=(), camera=(), microphone=(), payment=(), usb=(), interest-cohort=()" always; -add_header Cross-Origin-Opener-Policy "same-origin" always; -add_header Cross-Origin-Embedder-Policy "credentialless" always; -add_header Cross-Origin-Resource-Policy "cross-origin" always; -`; + const docsDirectives = [ + `default-src 'self'`, + `script-src 'self' ${DOCS_INLINE_SCRIPT_HASHES.map((hash) => `'${hash}'`).join(' ')}`, + `style-src 'self' 'unsafe-inline' https://fonts.googleapis.com`, + `img-src 'self' data: blob: https:`, + `font-src 'self' data: https://fonts.gstatic.com ${fontOrigins.join(' ')}`.trim(), + `connect-src 'self'${githubApiSource} https://fonts.gstatic.com ${connectOrigins.join(' ')}`.trim(), + `object-src 'none'`, + `base-uri 'self'`, + `frame-ancestors 'self'`, + `form-action 'self'`, + ]; -const contents = `add_header Content-Security-Policy "${csp}" always; -${commonHeaders}`; + const csp = directives.join('; '); + const docsCsp = docsDirectives.join('; '); -const docsContents = `add_header Content-Security-Policy "${docsCsp}" always; -${commonHeaders}`; + const nginxHeaders = (policy) => + [['Content-Security-Policy', policy], ...COMMON_HEADERS] + .map(([name, value]) => `add_header ${name} "${value}" always;\n`) + .join(''); -const outPath = join(repoRoot, 'security-headers.conf'); -const docsOutPath = join(repoRoot, 'security-headers-docs.conf'); -writeFileSync(outPath, contents); -writeFileSync(docsOutPath, docsContents); -console.log( - `[security-headers] wrote ${outPath} with ${scriptOrigins.length} script-src / ${connectOrigins.length} connect-src origin(s)` -); -console.log(`[security-headers] wrote ${docsOutPath} (docs CSP)`); + const staticHeaders = [ + '/*', + ` Content-Security-Policy: ${csp}`, + ...COMMON_HEADERS.map(([name, value]) => ` ${name}: ${value}`), + '', + ...['/docs', '/docs/*'].flatMap((docsPath) => [ + docsPath, + ' ! Content-Security-Policy', + ` Content-Security-Policy: ${docsCsp}`, + '', + ]), + ].join('\n'); + + return { + csp, + docsCsp, + scriptSources, + connectOrigins, + nginx: nginxHeaders(csp), + nginxDocs: nginxHeaders(docsCsp), + staticHeaders, + }; +} + +function main() { + const headers = buildSecurityHeaders(); + const outPath = join(repoRoot, 'security-headers.conf'); + const docsOutPath = join(repoRoot, 'security-headers-docs.conf'); + writeFileSync(outPath, headers.nginx); + writeFileSync(docsOutPath, headers.nginxDocs); + console.log( + `[security-headers] wrote ${outPath} with ${headers.scriptSources.length} script-src / ${headers.connectOrigins.length} connect-src source(s)` + ); + console.log(`[security-headers] wrote ${docsOutPath} (docs CSP)`); + const distDir = join(repoRoot, 'dist'); + const customHeaders = join(repoRoot, 'public', '_headers'); + if (existsSync(customHeaders)) { + console.log( + `[security-headers] kept your own ${customHeaders} for static hosts` + ); + } else if (existsSync(distDir)) { + const distOutPath = join(distDir, '_headers'); + writeFileSync(distOutPath, headers.staticHeaders); + console.log(`[security-headers] wrote ${distOutPath}`); + } +} + +if ( + process.argv[1] && + resolve(process.argv[1]) === fileURLToPath(import.meta.url) +) { + main(); +} diff --git a/scripts/generate-sitemap.mjs b/scripts/generate-sitemap.mjs index 9a6f87f8..9632c3e0 100644 --- a/scripts/generate-sitemap.mjs +++ b/scripts/generate-sitemap.mjs @@ -1,5 +1,6 @@ import fs from 'fs'; import path from 'path'; +import { execFileSync } from 'child_process'; import { fileURLToPath } from 'url'; const __filename = fileURLToPath(import.meta.url); @@ -17,6 +18,26 @@ const languages = fs.readdirSync(LOCALES_DIR).filter((file) => { return fs.statSync(path.join(LOCALES_DIR, file)).isDirectory(); }); +const toCamelCase = (str) => { + return str.replace(/-([a-z])/g, (g) => g[1].toUpperCase()); +}; + +const toolsByLang = {}; +for (const lang of languages) { + const toolsPath = path.join(LOCALES_DIR, lang, 'tools.json'); + toolsByLang[lang] = fs.existsSync(toolsPath) + ? JSON.parse(fs.readFileSync(toolsPath, 'utf-8')) + : {}; +} + +function alternateLangsFor(pageName) { + if (pageName === 'index') return languages; + const translationKey = toCamelCase(pageName); + return languages.filter( + (l) => l === 'en' || Boolean(toolsByLang[l][translationKey]) + ); +} + const PRIORITY_MAP = { index: 1.0, tools: 0.9, @@ -32,9 +53,17 @@ const PRIORITY_MAP = { 'excel-to-pdf': 0.9, 'powerpoint-to-pdf': 0.9, 'jpg-to-pdf': 0.9, - 'pdf-to-docx': 0.9, + 'pdf-to-word': 0.9, + 'unlock-pdf': 0.9, + 'protect-pdf': 0.9, 'pdf-to-excel': 0.9, 'pdf-to-jpg': 0.9, + 'compress-pdf-to-100kb': 0.8, + 'compress-pdf-to-200kb': 0.8, + 'compress-pdf-to-500kb': 0.8, + 'compress-pdf-to-1mb': 0.8, + 'compress-pdf-to-2mb': 0.8, + 'compress-pdf-for-email': 0.8, about: 0.8, faq: 0.8, contact: 0.7, @@ -52,7 +81,7 @@ function buildUrl(lang, pageName) { if (lang === 'en') { return pagePath ? `${SITE_URL}/${pagePath}` : SITE_URL; } - return pagePath ? `${SITE_URL}/${lang}/${pagePath}` : `${SITE_URL}/${lang}`; + return pagePath ? `${SITE_URL}/${lang}/${pagePath}` : `${SITE_URL}/${lang}/`; } function generateSitemap() { @@ -66,22 +95,48 @@ function generateSitemap() { .map((file) => file.replace('.html', '')) .filter((name) => !EXCLUDED_PAGES.has(name)); + const projectRoot = path.resolve(__dirname, '..'); const lastModCache = new Map(); - const getLastMod = (lang, pageName) => { - const cacheKey = `${lang}::${pageName}`; - if (lastModCache.has(cacheKey)) return lastModCache.get(cacheKey); + const getLastMod = (pageName) => { + if (lastModCache.has(pageName)) return lastModCache.get(pageName); const fileName = `${pageName}.html`; - const filePath = - lang === 'en' - ? path.join(DIST_DIR, fileName) - : path.join(DIST_DIR, lang, fileName); - let iso; - try { - iso = fs.statSync(filePath).mtime.toISOString().slice(0, 10); - } catch { - iso = new Date().toISOString().slice(0, 10); + const candidates = [ + path.join(projectRoot, 'src', 'pages', fileName), + path.join(projectRoot, fileName), + ]; + let iso = null; + for (const sourcePath of candidates) { + if (!fs.existsSync(sourcePath)) continue; + try { + const out = execFileSync( + 'git', + ['log', '-1', '--format=%cI', '--', sourcePath], + { cwd: projectRoot, encoding: 'utf-8' } + ).trim(); + if (out) iso = out.slice(0, 10); + } catch { + iso = null; + } + if (!iso) { + try { + iso = fs.statSync(sourcePath).mtime.toISOString().slice(0, 10); + } catch { + iso = null; + } + } + break; } - lastModCache.set(cacheKey, iso); + if (!iso) { + try { + iso = fs + .statSync(path.join(DIST_DIR, fileName)) + .mtime.toISOString() + .slice(0, 10); + } catch { + iso = new Date().toISOString().slice(0, 10); + } + } + lastModCache.set(pageName, iso); return iso; }; @@ -93,16 +148,15 @@ function generateSitemap() { for (const pageName of htmlFiles) { const priority = getPriority(pageName); const url = buildUrl('en', pageName); - const lastmod = getLastMod('en', pageName); + const lastmod = getLastMod(pageName); sitemap += ` ${url} ${lastmod} - weekly ${priority} `; - for (const altLang of languages) { + for (const altLang of alternateLangsFor(pageName)) { const altUrl = buildUrl(altLang, pageName); sitemap += ` `; @@ -114,6 +168,48 @@ function generateSitemap() { `; } + const blogDir = path.join(DIST_DIR, 'blog'); + let blogCount = 0; + if (fs.existsSync(blogDir)) { + const blogFiles = fs + .readdirSync(blogDir) + .filter((file) => file.endsWith('.html')) + .map((file) => file.replace('.html', '')); + for (const name of blogFiles) { + const url = + name === 'index' ? `${SITE_URL}/blog/` : `${SITE_URL}/blog/${name}`; + const sourcePath = path.join(projectRoot, 'blog', `${name}.html`); + let lastmod; + try { + const out = execFileSync( + 'git', + ['log', '-1', '--format=%cI', '--', sourcePath], + { cwd: projectRoot, encoding: 'utf-8' } + ).trim(); + lastmod = out ? out.slice(0, 10) : null; + } catch { + lastmod = null; + } + if (!lastmod) { + try { + lastmod = fs.statSync(sourcePath).mtime.toISOString().slice(0, 10); + } catch { + lastmod = new Date().toISOString().slice(0, 10); + } + } + sitemap += ` + ${url} + ${lastmod} + 0.7 + +`; + blogCount++; + } + } + if (blogCount > 0) { + console.log(` Blog: ${blogCount} URLs added (English only)`); + } + sitemap += ` `; diff --git a/scripts/generate-static-tool-links.mjs b/scripts/generate-static-tool-links.mjs new file mode 100644 index 00000000..c0bc4756 --- /dev/null +++ b/scripts/generate-static-tool-links.mjs @@ -0,0 +1,106 @@ +import fs from 'fs'; +import path from 'path'; +import { fileURLToPath } from 'url'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); + +const TOOLS_CONFIG = path.resolve(__dirname, '../src/js/config/tools.ts'); +const PAGES_DIR = path.resolve(__dirname, '../src/pages'); +const PARTIAL_PATH = path.resolve( + __dirname, + '../src/partials/tool-links-static.html' +); + +function escapeHtml(value) { + return value + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"'); +} + +function unescapeTs(value) { + return value.replace(/\\'/g, "'").replace(/\\\\/g, '\\'); +} + +function parseCategories(source) { + const headers = [ + ...source.matchAll(/name:\s*'((?:[^'\\]|\\.)*)',\s*\n\s*tools:\s*\[/g), + ].map((m) => ({ name: unescapeTs(m[1]), index: m.index })); + + const toolMatches = [ + ...source.matchAll( + /\{[^{}]*?href:\s*import\.meta\.env\.BASE_URL\s*\+\s*'((?:[^'\\]|\\.)*)'[^{}]*?\}/gs + ), + ]; + const categories = headers.map((h) => ({ name: h.name, tools: [] })); + for (const match of toolMatches) { + const block = match[0]; + const hrefFile = unescapeTs(match[1]); + const nameMatch = block.match(/name:\s*'((?:[^'\\]|\\.)*)'/s); + const subtitleMatch = block.match(/subtitle:\s*\n?\s*'((?:[^'\\]|\\.)*)'/s); + if (!nameMatch) continue; + + let categoryIndex = -1; + for (let i = 0; i < headers.length; i++) { + if (headers[i].index < match.index) categoryIndex = i; + } + if (categoryIndex === -1) continue; + + categories[categoryIndex].tools.push({ + slug: hrefFile.replace(/\.html$/, ''), + name: unescapeTs(nameMatch[1]), + subtitle: subtitleMatch ? unescapeTs(subtitleMatch[1]) : '', + }); + } + return categories.filter((c) => c.tools.length > 0); +} + +function generate() { + const source = fs.readFileSync(TOOLS_CONFIG, 'utf-8'); + const categories = parseCategories(source); + const existingPages = new Set( + fs + .readdirSync(PAGES_DIR) + .filter((f) => f.endsWith('.html')) + .map((f) => f.replace(/\.html$/, '')) + ); + + const seen = new Set(); + const lines = []; + let toolCount = 0; + for (const category of categories) { + const tools = category.tools.filter( + (t) => existingPages.has(t.slug) && !seen.has(t.slug) + ); + if (tools.length === 0) continue; + for (const t of tools) seen.add(t.slug); + lines.push( + `

${escapeHtml(category.name)}

` + ); + for (const tool of tools) { + toolCount++; + lines.push( + `` + + `

${escapeHtml(tool.name)}

` + + `

${escapeHtml(tool.subtitle)}

` + + `
` + ); + } + } + + if (toolCount < 50) { + console.error( + `generate-static-tool-links: only ${toolCount} tools parsed from tools.ts — parser is likely out of sync with the config format` + ); + process.exit(1); + } + + fs.writeFileSync(PARTIAL_PATH, lines.join('\n') + '\n'); + console.log( + `Static tool links: ${toolCount} tools in ${categories.length} categories -> src/partials/tool-links-static.html` + ); +} + +generate(); diff --git a/scripts/include-docs-in-dist.js b/scripts/include-docs-in-dist.js index 64c4b935..c1e97563 100644 --- a/scripts/include-docs-in-dist.js +++ b/scripts/include-docs-in-dist.js @@ -1,6 +1,8 @@ +import crypto from 'crypto'; import fs from 'fs'; import path from 'path'; import { fileURLToPath } from 'url'; +import { DOCS_INLINE_SCRIPT_HASHES } from './generate-security-headers.mjs'; const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); @@ -11,46 +13,133 @@ const mainDistPath = path.join(projectRoot, 'dist'); const targetDocsPath = path.join(mainDistPath, 'docs'); function copyDir(src, dest) { - if (!fs.existsSync(dest)) { - fs.mkdirSync(dest, { recursive: true }); + if (!fs.existsSync(dest)) { + fs.mkdirSync(dest, { recursive: true }); + } + + const entries = fs.readdirSync(src, { withFileTypes: true }); + + for (const entry of entries) { + const srcPath = path.join(src, entry.name); + const destPath = path.join(dest, entry.name); + + if (entry.isDirectory()) { + copyDir(srcPath, destPath); + } else { + fs.copyFileSync(srcPath, destPath); } + } +} - const entries = fs.readdirSync(src, { withFileTypes: true }); - - for (const entry of entries) { - const srcPath = path.join(src, entry.name); - const destPath = path.join(dest, entry.name); - - if (entry.isDirectory()) { - copyDir(srcPath, destPath); - } else { - fs.copyFileSync(srcPath, destPath); - } +function findUnlistedInlineScripts(dir, unlisted = new Set()) { + for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { + const entryPath = path.join(dir, entry.name); + if (entry.isDirectory()) { + findUnlistedInlineScripts(entryPath, unlisted); + continue; } + if (!entry.name.endsWith('.html')) continue; + const html = fs.readFileSync(entryPath, 'utf8'); + const inlineScripts = html.matchAll( + /]*\bsrc=)([^>]*)>([\s\S]*?)<\/script>/g + ); + for (const [, attributes, code] of inlineScripts) { + if ( + !code.trim() || + /type=["']?application\/(ld\+)?json/.test(attributes) + ) { + continue; + } + const hash = `sha256-${crypto.createHash('sha256').update(code).digest('base64')}`; + if (!DOCS_INLINE_SCRIPT_HASHES.includes(hash)) unlisted.add(hash); + } + } + return unlisted; } console.log('Including docs in dist...'); if (!fs.existsSync(docsDistPath)) { - console.error('Error: Docs build not found. Please run "npm run docs:build" first.'); - process.exit(1); + console.error( + 'Error: Docs build not found. Please run "npm run docs:build" first.' + ); + process.exit(1); } if (!fs.existsSync(mainDistPath)) { - console.error('Error: Main dist folder not found. Please run "npm run build" first.'); - process.exit(1); + console.error( + 'Error: Main dist folder not found. Please run "npm run build" first.' + ); + process.exit(1); +} + +const unlistedScripts = findUnlistedInlineScripts(docsDistPath); +if (unlistedScripts.size > 0) { + console.error( + 'Error: The docs contain inline scripts that the docs Content-Security-Policy does not allow:' + ); + for (const hash of unlistedScripts) console.error(` '${hash}'`); + console.error( + 'Add these hashes to DOCS_INLINE_SCRIPT_HASHES in scripts/generate-security-headers.mjs and to the /docs script-src in vercel.json and serve.json, then rebuild.' + ); + process.exit(1); } try { - console.log(`Copying from: ${docsDistPath}`); - console.log(`Copying to: ${targetDocsPath}`); + console.log(`Copying from: ${docsDistPath}`); + console.log(`Copying to: ${targetDocsPath}`); - copyDir(docsDistPath, targetDocsPath); + copyDir(docsDistPath, targetDocsPath); - console.log('Docs successfully included in dist/docs!'); + console.log('Docs successfully included in dist/docs!'); - const files = fs.readdirSync(targetDocsPath); - console.log(`Copied ${files.length} items to dist/docs`); + const files = fs.readdirSync(targetDocsPath); + console.log(`Copied ${files.length} items to dist/docs`); + + addDocsToSitemap(); } catch (error) { - console.error('Error copying docs:', error.message); - process.exit(1); + console.error('Error copying docs:', error.message); + process.exit(1); +} + +function listDocsHtml(dir, prefix) { + const urls = []; + for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { + if (entry.isDirectory()) { + if (entry.name === 'assets' || entry.name.startsWith('.')) continue; + urls.push( + ...listDocsHtml(path.join(dir, entry.name), `${prefix}/${entry.name}`) + ); + } else if (entry.name.endsWith('.html') && entry.name !== '404.html') { + const slug = entry.name.replace(/\.html$/, ''); + urls.push(slug === 'index' ? `${prefix}/` : `${prefix}/${slug}`); + } + } + return urls; +} + +function addDocsToSitemap() { + const siteUrl = (process.env.SITE_URL || 'https://www.bentopdf.com').replace( + /\/+$/, + '' + ); + const sitemapPath = path.join(mainDistPath, 'sitemap.xml'); + if (!fs.existsSync(sitemapPath)) { + console.warn( + 'sitemap.xml not found in dist; skipping docs sitemap entries' + ); + return; + } + let xml = fs.readFileSync(sitemapPath, 'utf-8'); + if (xml.includes(`${siteUrl}/docs/`)) return; + + const urls = listDocsHtml(targetDocsPath, '/docs'); + const entries = urls + .map( + (url) => + ` \n ${siteUrl}${url}\n 0.6\n \n` + ) + .join(''); + xml = xml.replace('', `${entries}`); + fs.writeFileSync(sitemapPath, xml); + console.log(`Added ${urls.length} docs URLs to sitemap.xml`); } diff --git a/scripts/json-ld.mjs b/scripts/json-ld.mjs new file mode 100644 index 00000000..7c6a016d --- /dev/null +++ b/scripts/json-ld.mjs @@ -0,0 +1,6 @@ +export function serializeJsonLd(data) { + return JSON.stringify(data, null, 2) + .replace(/ fs.statSync(path.join(LOCALES_DIR, d)).isDirectory()) +); + +const KEY_MAPPING = { index: 'home', 404: 'notFound' }; +const toCamelCase = (str) => + str.replace(/-([a-z])/g, (g) => g[1].toUpperCase()); + +const toolsByLang = {}; +for (const lang of LOCALE_DIRS) { + const toolsPath = path.join(LOCALES_DIR, lang, 'tools.json'); + toolsByLang[lang] = fs.existsSync(toolsPath) + ? JSON.parse(fs.readFileSync(toolsPath, 'utf-8')) + : {}; +} + function expectedCanonicalForFile(rel) { const parts = rel.split('/'); const fileName = parts.pop(); const baseName = fileName.replace(/\.html$/, ''); const slug = baseName === 'index' ? '' : baseName; + const translationKey = KEY_MAPPING[baseName] || toCamelCase(baseName); const segments = [SITE_URL]; if (BASE_PATH) segments.push(BASE_PATH.replace(/^\//, '')); + for (const dir of parts) { + if (dir === 'blog') { + segments.push(dir); + } else if (LOCALE_DIRS.has(dir)) { + const translated = + baseName === 'index' || + Boolean(toolsByLang[dir] && toolsByLang[dir][translationKey]); + if (translated) segments.push(dir); + } + } if (slug) segments.push(slug); return segments.join('/').replace(/\/+$/, '') || SITE_URL; } -function auditHtml(file) { +function auditHtml(file, knownSlugs) { const html = fs.readFileSync(file.full, 'utf-8'); + const isLocalized = file.rel.split('/').some((seg) => LOCALE_DIRS.has(seg)); + + if (!isLocalized && /[—–]/.test(html)) { + fail( + 'em-dash', + `${file.rel}: contains an em or en dash (house style forbids them)` + ); + } + + if (!isLocalized) { + const internal = findAll(html, /]+href=["'](\/[^"'#?]*)["']/g); + for (const href of internal) { + const clean = href.replace(/\/$/, ''); + if (clean === '') continue; + const seg = clean.slice(1); + if (seg.includes('/')) continue; + if (LOCALE_DIRS.has(seg) || seg === 'blog' || seg === 'docs') continue; + if (!knownSlugs.has(seg)) { + fail( + 'dead-link', + `${file.rel}: links to "${href}" but no such page exists in dist` + ); + } + } + } const titles = findAll(html, /]*>[\s\S]*?<\/title>/g); if (titles.length === 0) { fail('title', `${file.rel}: no `); @@ -147,7 +201,7 @@ function auditHtml(file) { ); } - const emptyI18n = html.match(/<span[^>]*data-i18n="[^"]+"[^>]*>\s*<\/span>/g); + const emptyI18n = html.match(/<span[^>]*data-i18n="[^"]+"[^>]*><\/span>/g); if (emptyI18n && emptyI18n.length > 0) { fail( 'data-i18n', @@ -176,10 +230,76 @@ function auditHtml(file) { `${file.rel}: hreflang href host "${url.hostname}" != expected "${HOST}"` ); } + const trimmed = url.pathname.replace(/^\/|\/$/g, ''); + if (LOCALE_DIRS.has(trimmed) && !url.pathname.endsWith('/')) { + fail( + 'hreflang', + `${file.rel}: language-home hreflang "${m[1]}" must end with "/" (the server 301s the slashless form)` + ); + } } catch { fail('hreflang', `${file.rel}: hreflang href "${m[1]}" not a valid URL`); } } + + const descriptions = findAll( + html, + /<meta[^>]+name=["']description["'][^>]*content=["']([^"']*)["']/g + ); + if (descriptions.length > 1) { + fail( + 'description', + `${file.rel}: ${descriptions.length} meta description tags (expected 1)` + ); + } + for (const desc of descriptions) { + if (desc.includes('★')) { + fail('description', `${file.rel}: meta description contains "★"`); + } + } + + const isTopLevel = !file.rel.includes('/'); + if (isTopLevel) { + const socialImages = findAll( + html, + /<meta[^>]+(?:property=["']og:image["']|name=["']twitter:image["'])[^>]*content=["']([^"']+)["']/g + ); + for (const image of socialImages) { + let pathname; + try { + pathname = image.startsWith('http') ? new URL(image).pathname : image; + } catch { + continue; + } + if ( + pathname.startsWith('/images/') && + !fs.existsSync(path.join(DIST_DIR, pathname.replace(/^\//, ''))) + ) { + fail( + 'social-image', + `${file.rel}: og/twitter image "${pathname}" does not exist in dist` + ); + } + } + + if (html.includes('faq-d') && !html.includes('"FAQPage"')) { + fail( + 'faq-schema', + `${file.rel}: visible FAQ section without FAQPage JSON-LD` + ); + } + + const internalHtmlLinks = findAll( + html, + /<a[^>]+href=["'](\/?[a-z0-9][a-z0-9-]*\.html)["']/g + ); + for (const href of internalHtmlLinks) { + fail( + 'internal-link', + `${file.rel}: internal link "${href}" should be extensionless` + ); + } + } } function auditSitemap() { @@ -248,7 +368,12 @@ function main() { fail('audit', 'no HTML files found in dist/'); } - for (const file of files) auditHtml(file); + const knownSlugs = new Set( + files + .filter((f) => !f.rel.includes('/')) + .map((f) => f.rel.replace(/\.html$/, '')) + ); + for (const file of files) auditHtml(file, knownSlugs); auditSitemap(); const total = failures.length + warnings.length; diff --git a/security-headers-docs.conf b/security-headers-docs.conf deleted file mode 100644 index 33f5b37c..00000000 --- a/security-headers-docs.conf +++ /dev/null @@ -1,9 +0,0 @@ -add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; img-src 'self' data: blob: https:; font-src 'self' data: https://fonts.gstatic.com https://rawcdn.githack.com; connect-src 'self' https://api.github.com https://fonts.gstatic.com https://cdn.jsdelivr.net https://bentopdf-cors-proxy.bentopdf.workers.dev https://rawcdn.githack.com; object-src 'none'; base-uri 'self'; frame-ancestors 'self'; form-action 'self'" always; -add_header X-Frame-Options "SAMEORIGIN" always; -add_header X-Content-Type-Options "nosniff" always; -add_header X-XSS-Protection "1; mode=block" always; -add_header Referrer-Policy "strict-origin-when-cross-origin" always; -add_header Permissions-Policy "geolocation=(), camera=(), microphone=(), payment=(), usb=(), interest-cohort=()" always; -add_header Cross-Origin-Opener-Policy "same-origin" always; -add_header Cross-Origin-Embedder-Policy "credentialless" always; -add_header Cross-Origin-Resource-Policy "cross-origin" always; diff --git a/serve.json b/serve.json index 34511276..ee1df54c 100644 --- a/serve.json +++ b/serve.json @@ -3,6 +3,10 @@ { "source": "**/*", "headers": [ + { + "key": "Content-Security-Policy", + "value": "default-src 'self'; script-src 'self' 'wasm-unsafe-eval' 'unsafe-eval' blob: https://cdn.jsdelivr.net/npm/@bentopdf/pymupdf-wasm@0.11.16/ https://cdn.jsdelivr.net/npm/@bentopdf/gs-wasm@0.1.1/assets/ https://cdn.jsdelivr.net/npm/coherentpdf@2.5.5/dist/ https://cdn.jsdelivr.net/npm/tesseract.js@v7.0.0/dist/ https://cdn.jsdelivr.net/npm/tesseract.js-core@v7.0.0/; worker-src 'self' blob:; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; img-src 'self' data: blob: https:; font-src 'self' data: https://fonts.gstatic.com https://rawcdn.githack.com; connect-src 'self' blob: https://api.github.com https://fonts.gstatic.com https://cdn.jsdelivr.net https://bentopdf-cors-proxy.bentopdf.workers.dev https://rawcdn.githack.com; object-src 'none'; base-uri 'self'; frame-src 'self' blob:; frame-ancestors 'self'; form-action 'self'" + }, { "key": "Cross-Origin-Opener-Policy", "value": "same-origin" @@ -20,16 +24,15 @@ "value": "nosniff" } ] - } - ], - "rewrites": [ - { - "source": "/{lang}/**", - "destination": "/{lang}/index.html" }, { - "source": "**", - "destination": "/index.html" + "source": "docs/**", + "headers": [ + { + "key": "Content-Security-Policy", + "value": "default-src 'self'; script-src 'self' 'sha256-DQUgNM9X0iH2019NkUxeBvnBoEoRKkHBc/I0iLqPNPA=' 'sha256-La1r0VSk0Po4KFI0duEKhmPu+u0I416JW3oONqtdf4M='; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; img-src 'self' data: blob: https:; font-src 'self' data: https://fonts.gstatic.com https://rawcdn.githack.com; connect-src 'self' https://api.github.com https://fonts.gstatic.com https://cdn.jsdelivr.net https://bentopdf-cors-proxy.bentopdf.workers.dev https://rawcdn.githack.com; object-src 'none'; base-uri 'self'; frame-ancestors 'self'; form-action 'self'" + } + ] } ], "cleanUrls": true, diff --git a/signatures/cla.json b/signatures/cla.json index 8cf397d0..074ef38e 100644 --- a/signatures/cla.json +++ b/signatures/cla.json @@ -575,6 +575,110 @@ "created_at": "2026-08-26T12:51:13Z", "repoId": 1074785178, "pullRequestNo": 845 + }, + { + "name": "tsueri", + "id": 13819344, + "comment_id": 5507876766, + "created_at": "2026-09-02T10:05:09Z", + "repoId": 1074785178, + "pullRequestNo": 851 + }, + { + "name": "ynlamy", + "id": 18055960, + "comment_id": 5525643798, + "created_at": "2026-09-03T12:19:31Z", + "repoId": 1074785178, + "pullRequestNo": 757 + }, + { + "name": "franklioxygen", + "id": 2637636, + "comment_id": 5591002573, + "created_at": "2026-09-08T19:55:31Z", + "repoId": 1074785178, + "pullRequestNo": 865 + }, + { + "name": "carminezac", + "id": 60395145, + "comment_id": 5656274573, + "created_at": "2026-09-13T21:29:49Z", + "repoId": 1074785178, + "pullRequestNo": 869 + }, + { + "name": "Ahson-Shaikh", + "id": 59800198, + "comment_id": 5696805852, + "created_at": "2026-09-16T11:37:36Z", + "repoId": 1074785178, + "pullRequestNo": 870 + }, + { + "name": "adrienboulle", + "id": 14262895, + "comment_id": 5759905323, + "created_at": "2026-09-21T11:42:42Z", + "repoId": 1074785178, + "pullRequestNo": 887 + }, + { + "name": "Harrunos", + "id": 178315485, + "comment_id": 5850541029, + "created_at": "2026-09-26T22:39:15Z", + "repoId": 1074785178, + "pullRequestNo": 895 + }, + { + "name": "anishgowda21", + "id": 54987116, + "comment_id": 6162547855, + "created_at": "2026-10-14T06:42:42Z", + "repoId": 1074785178, + "pullRequestNo": 914 + }, + { + "name": "sergitost10", + "id": 118278737, + "comment_id": 6187482587, + "created_at": "2026-10-15T08:05:57Z", + "repoId": 1074785178, + "pullRequestNo": 916 + }, + { + "name": "sadfun", + "id": 32015025, + "comment_id": 6191680212, + "created_at": "2026-10-15T12:08:17Z", + "repoId": 1074785178, + "pullRequestNo": 917 + }, + { + "name": "kelseyjmw", + "id": 130060378, + "comment_id": 6219871448, + "created_at": "2026-10-16T16:22:37Z", + "repoId": 1074785178, + "pullRequestNo": 919 + }, + { + "name": "oa-cmu", + "id": 321330886, + "comment_id": 6228767618, + "created_at": "2026-10-17T02:57:22Z", + "repoId": 1074785178, + "pullRequestNo": 921 + }, + { + "name": "cosark", + "id": 121065588, + "comment_id": 6246076575, + "created_at": "2026-10-18T05:24:56Z", + "repoId": 1074785178, + "pullRequestNo": 904 } ] } \ No newline at end of file diff --git a/simple-index.html b/simple-index.html index e74e1434..cd6140b6 100644 --- a/simple-index.html +++ b/simple-index.html @@ -60,7 +60,7 @@ class="h-8 w-8" /> <span class="text-white font-bold text-xl ml-2"> - <a href="/" + <a href="{{baseUrl}}" >{{#if brandName}}{{brandName}}{{else}}BentoPDF{{/if}}</a > </span> diff --git a/src/css/bookmark.css b/src/css/bookmark.css index ff8bd616..ca0481ec 100644 --- a/src/css/bookmark.css +++ b/src/css/bookmark.css @@ -1,5 +1,5 @@ body { - color: #374151; + color: #383838; } .sortable-ghost { diff --git a/src/css/edit-pdf-text.css b/src/css/edit-pdf-text.css index ff11cb22..ff337ab1 100644 --- a/src/css/edit-pdf-text.css +++ b/src/css/edit-pdf-text.css @@ -1,12 +1,12 @@ #text-editor-app { --ecbg: #111827; - --ecpanel: #1f2937; - --ecpanel2: #374151; - --ecline: #4b5563; + --ecpanel: #2a2a2a; + --ecpanel2: #383838; + --ecline: #444444; --ectext: #f3f4f6; --ecmuted: #9ca3af; --ecaccent: #6366f1; - --ecstage: #374151; + --ecstage: #383838; position: fixed; inset: 0; z-index: 40; @@ -50,7 +50,7 @@ user-select: none; } #text-editor-app .btn:hover { - background: #4b5563; + background: #444444; } #text-editor-app .btn:disabled { opacity: 0.4; @@ -197,7 +197,7 @@ color: var(--ecmuted); max-width: 460px; } -#text-editor-app #empty h1 { +#text-editor-app #empty h2 { color: var(--ectext); font-weight: 600; } @@ -1043,7 +1043,7 @@ cursor: pointer; } #text-editor-app .toolright button[data-tool]:hover { - background: #4b5563; + background: #444444; } #text-editor-app .toolright button[data-tool].on { background: var(--ecaccent); @@ -1285,7 +1285,7 @@ line-height: 1; } #text-editor-app .stepbtn:hover { - background: #4b5563; + background: #444444; color: var(--ectext); } #text-editor-app .stepper:has(.num:disabled) .stepbtn { @@ -1526,8 +1526,8 @@ border-bottom: none; border-top: 1px solid var(--ecline); border-radius: 16px 16px 0 0; - background: #1f2937; - background: var(--ecpanel, #1f2937); + background: #2a2a2a; + background: var(--ecpanel, #2a2a2a); box-shadow: 0 -12px 30px rgba(0, 0, 0, 0.45); z-index: 27; padding: 8px 14px calc(14px + env(safe-area-inset-bottom)); diff --git a/src/css/landing.css b/src/css/landing.css new file mode 100644 index 00000000..fb793816 --- /dev/null +++ b/src/css/landing.css @@ -0,0 +1,2153 @@ +body.lp { + --lp-bg: #222222; + --lp-bg-2: #2a2a2a; + --lp-bg-3: #383838; + --lp-well: #222222; + --lp-line: rgba(255, 255, 255, 0.1); + --lp-line-2: rgba(255, 255, 255, 0.2); + --lp-text: #f3f3f1; + --lp-text-2: #b4b4b0; + --lp-dim: #8d8d89; + --lp-accent: #6366f1; + --lp-accent-hover: #4f46e5; + --lp-accent-2: #818cf8; + --lp-accent-3: #a5b4fc; + --lp-white: #f4f4f1; + --lp-display: 'DM Sans', ui-sans-serif, system-ui, sans-serif; + --lp-mono: + ui-monospace, 'SF Mono', Menlo, Consolas, 'Liberation Mono', monospace; + --lp-pad: clamp(16px, 4vw, 48px); + background-color: var(--lp-bg); + color: var(--lp-text-2); +} + +body.lp ::selection { + background: rgba(99, 102, 241, 0.45); + color: #fff; +} + +.lp-frame { + position: relative; + width: min(1120px, calc(100% - 2rem)); + margin: 0 auto; +} + +@media (max-width: 639px) { + .lp-frame { + width: 100%; + } +} + +.lp-sec { + position: relative; +} + +.lp-band { + background: var(--lp-well); +} + +.lp [id] { + scroll-margin-top: 84px; +} + +.lp-nav { + position: sticky; + top: 0; + z-index: 30; + background: rgba(34, 34, 34, 0.86); + -webkit-backdrop-filter: blur(14px); + backdrop-filter: blur(14px); + border-bottom: 1px solid var(--lp-line); +} + +.lp-nav-inner { + display: flex; + align-items: stretch; + height: 64px; + border-left: 1px solid var(--lp-line); + border-right: 1px solid var(--lp-line); +} + +.lp-nav-logo { + display: flex; + align-items: center; + gap: 10px; + padding: 0 20px; + border-right: 1px solid var(--lp-line); + font-family: var(--lp-display); + font-size: 19px; + font-weight: 700; + letter-spacing: -0.02em; + color: var(--lp-text); +} + +.lp-nav-logo img { + width: 28px; + height: 28px; +} + +.lp-nav-links { + display: none; + align-items: center; + gap: 2px; + padding: 0 10px; + border-right: 1px solid var(--lp-line); +} + +.lp-nav-links a { + padding: 8px 12px; + border-radius: 4px; + font-size: 14px; + font-weight: 500; + color: var(--lp-text-2); + transition: + color 0.2s, + background-color 0.2s; +} + +.lp-nav-links a:hover { + color: var(--lp-text); + background: rgba(255, 255, 255, 0.05); +} + +.lp-nav-fill { + flex: 1; + background-image: repeating-linear-gradient( + 135deg, + rgba(255, 255, 255, 0.045) 0 1px, + transparent 1px 10px + ); +} + +.lp-nav-right { + display: flex; + align-items: center; + gap: 10px; + padding: 0 12px 0 14px; + border-left: 1px solid var(--lp-line); +} + +.lp-nav-gh { + display: inline-flex; + align-items: center; + gap: 6px; + height: 34px; + padding: 0 12px 0 10px; + border: 1px solid var(--lp-line-2); + border-radius: 6px; + font-size: 13px; + font-weight: 600; + color: var(--lp-text); + transition: + background-color 0.2s, + border-color 0.2s; +} + +.lp-nav-gh:hover { + background: rgba(255, 255, 255, 0.06); + border-color: rgba(255, 255, 255, 0.32); +} + +.lp-nav-gh svg { + width: 16px; + height: 16px; +} + +.lp-nav .lp-nav-cta { + display: none; +} + +.lp-nav-mobile { + display: flex; + align-items: center; + gap: 8px; +} + +.lp-nav-burger { + display: inline-flex; + align-items: center; + justify-content: center; + width: 40px; + height: 40px; + border: 1px solid transparent; + border-radius: 6px; + color: var(--lp-text-2); + transition: + color 0.2s, + border-color 0.2s; +} + +.lp-nav-burger:hover { + color: var(--lp-text); + border-color: var(--lp-line-2); +} + +.lp-nav-burger svg { + width: 22px; + height: 22px; +} + +.lp-mobile-menu { + border-top: 1px solid var(--lp-line); + background: var(--lp-well); +} + +.lp-mobile-menu a { + display: block; + padding: 14px var(--lp-pad); + border-bottom: 1px solid var(--lp-line); + font-size: 15px; + font-weight: 500; + color: var(--lp-text-2); + transition: color 0.2s; +} + +.lp-mobile-menu a:hover { + color: var(--lp-text); +} + +@media (min-width: 640px) { + .lp-nav .lp-nav-cta { + display: inline-flex; + } +} + +@media (min-width: 768px) { + .lp-nav-links { + display: flex; + } + + .lp-nav-mobile { + display: none; + } +} + +.lp-ribbon { + background: var(--lp-bg-2); + border-bottom: 1px solid var(--lp-line); +} + +.lp-ribbon-inner { + display: flex; + flex-wrap: wrap; + align-items: center; + justify-content: center; + gap: 10px 16px; + padding: 10px var(--lp-pad); + font-size: 13px; + color: var(--lp-text-2); +} + +.lp-pill-btn { + display: inline-flex; + align-items: center; + gap: 6px; + height: 28px; + padding: 0 12px; + border-radius: 5px; + font-size: 12px; + font-weight: 600; + transition: + background-color 0.2s, + border-color 0.2s, + color 0.2s; +} + +.lp-pill-btn svg { + width: 13px; + height: 13px; +} + +.lp-pill-btn--accent { + background: var(--lp-accent); + color: #fff; +} + +.lp-pill-btn--accent:hover { + background: var(--lp-accent-hover); +} + +.lp-pill-btn--ghost { + border: 1px solid var(--lp-line-2); + color: var(--lp-text); +} + +.lp-pill-btn--ghost:hover { + background: rgba(255, 255, 255, 0.06); + border-color: rgba(255, 255, 255, 0.32); +} + +.lp-btn { + position: relative; + display: inline-flex; + align-items: center; + justify-content: center; + gap: 10px; + height: 48px; + padding: 0 22px; + border-radius: 6px; + font-size: 14px; + font-weight: 600; + white-space: nowrap; + cursor: pointer; + transition: + background-color 0.2s, + border-color 0.2s, + color 0.2s, + transform 0.2s, + box-shadow 0.2s; +} + +.lp-btn svg { + width: 16px; + height: 16px; + transition: transform 0.2s; +} + +.lp-btn:hover svg:last-child { + transform: translateX(3px); +} + +.lp-btn:focus-visible { + outline: 2px solid var(--lp-accent-3); + outline-offset: 3px; +} + +.lp-btn--primary { + background: var(--lp-accent); + color: #fff; + box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.14); +} + +.lp-btn--primary:hover { + background: var(--lp-accent-hover); + transform: translateY(-1px); +} + +.lp-btn--ghost { + border: 1px solid var(--lp-line-2); + color: var(--lp-text); +} + +.lp-btn--ghost:hover { + background: rgba(255, 255, 255, 0.05); + border-color: rgba(255, 255, 255, 0.32); +} + +.lp-btn--ghost svg { + transition: none; +} + +.lp-btn--ghost:hover svg { + transform: none; +} + +.lp-btn--sm { + height: 38px; + padding: 0 14px; + font-size: 13px; +} + +.lp-brackets { + display: inline-block; +} + +.lp-cross::after { + content: ''; + position: absolute; + inset: -5px; + z-index: 2; + pointer-events: none; + background-repeat: no-repeat; + background-image: + linear-gradient(rgba(255, 255, 255, 0.4), rgba(255, 255, 255, 0.4)), + linear-gradient(rgba(255, 255, 255, 0.4), rgba(255, 255, 255, 0.4)), + linear-gradient(rgba(255, 255, 255, 0.4), rgba(255, 255, 255, 0.4)), + linear-gradient(rgba(255, 255, 255, 0.4), rgba(255, 255, 255, 0.4)), + linear-gradient(rgba(255, 255, 255, 0.4), rgba(255, 255, 255, 0.4)), + linear-gradient(rgba(255, 255, 255, 0.4), rgba(255, 255, 255, 0.4)), + linear-gradient(rgba(255, 255, 255, 0.4), rgba(255, 255, 255, 0.4)), + linear-gradient(rgba(255, 255, 255, 0.4), rgba(255, 255, 255, 0.4)); + background-size: + 11px 1px, + 1px 11px, + 11px 1px, + 1px 11px, + 11px 1px, + 1px 11px, + 11px 1px, + 1px 11px; + background-position: + 0 5px, + 5px 0, + 100% 5px, + calc(100% - 5px) 0, + 0 calc(100% - 5px), + 5px 100%, + 100% calc(100% - 5px), + calc(100% - 5px) 100%; +} + +.lp-kicker { + display: flex; + align-items: center; + gap: 14px; + margin: 0; + font-size: 14px; + font-weight: 500; + letter-spacing: 0.01em; + color: var(--lp-accent-3); +} + +.lp-kicker::before, +.lp-kicker::after { + content: ''; + flex: none; + width: 28px; + height: 1px; + background: currentColor; + opacity: 0.7; +} + +.lp-kicker::after { + display: none; +} + +.lp-head--center .lp-kicker, +.lp-cta .lp-kicker { + justify-content: center; +} + +.lp-head--center .lp-kicker::after, +.lp-cta .lp-kicker::after { + display: block; +} + +.lp-head { + display: grid; + gap: 24px; + align-items: end; + padding: clamp(64px, 9vw, 112px) var(--lp-pad) clamp(40px, 5vw, 64px); +} + +.lp-head-side p { + margin: 0; + font-size: 15px; + line-height: 1.6; + color: var(--lp-text-2); +} + +.lp-head--center { + text-align: center; + justify-items: center; +} + +.lp-head-p { + max-width: 560px; + margin: 18px auto 0; + font-size: 16px; + line-height: 1.6; + color: var(--lp-text-2); +} + +@media (min-width: 900px) { + .lp-head:not(.lp-head--center) { + grid-template-columns: 1fr minmax(220px, 300px); + } + + .lp-head-side { + text-align: right; + padding-bottom: 8px; + } +} + +.lp-h1, +.lp-h2 { + margin: 20px 0 0; + font-family: var(--lp-display); + font-weight: 700; + line-height: 1.02; + letter-spacing: -0.03em; + color: var(--lp-text); + text-wrap: balance; +} + +.lp-h1 { + font-size: clamp(42px, 7.6vw, 84px); + letter-spacing: -0.035em; + line-height: 0.98; +} + +.lp-h2 { + font-size: clamp(34px, 4.8vw, 56px); +} + +.lp-h1 .line, +.lp-h2 .line { + display: block; +} + +.lp-h1 .dim, +.lp-h2 .dim { + color: var(--lp-dim); +} + +.lp-h1 .dot { + color: var(--lp-accent-2); +} + +.lp-chip { + display: inline-grid; + place-items: center; + width: 0.8em; + height: 0.8em; + margin-left: 0.18em; + vertical-align: 0.125em; + border-radius: 0.18em; + background: var(--lp-accent); + color: #fff; + box-shadow: 0 0.2em 0.6em rgba(0, 0, 0, 0.35); + transform: rotate(-6deg); +} + +.lp-chip svg { + width: 58%; + height: 58%; +} + +.lp-hero::before { + content: ''; + position: absolute; + inset: 0; + pointer-events: none; + background: radial-gradient( + 50% 42% at 50% 0%, + rgba(99, 102, 241, 0.16), + transparent 70% + ); +} + +.lp-hero { + position: relative; + overflow: hidden; + padding: clamp(72px, 11vw, 140px) var(--lp-pad) clamp(64px, 8vw, 104px); + text-align: center; +} + +.lp-hero-bg { + position: absolute; + inset: 0; + pointer-events: none; + background-image: + linear-gradient(to right, var(--lp-line) 1px, transparent 1px), + linear-gradient(to bottom, var(--lp-line) 1px, transparent 1px); + background-size: 56px 56px; + background-position: 50% 0; + -webkit-mask-image: radial-gradient( + ellipse 62% 72% at 50% 44%, + #000 18%, + transparent 74% + ); + mask-image: radial-gradient( + ellipse 62% 72% at 50% 44%, + #000 18%, + transparent 74% + ); +} + +.lp-hero-bg i { + position: absolute; + width: 56px; + height: 56px; + background: rgba(255, 255, 255, 0.045); +} + +.lp-hero-bg i:nth-child(1) { + left: calc(50% - 28px - 336px); + top: 56px; +} + +.lp-hero-bg i:nth-child(2) { + left: calc(50% - 28px + 280px); + top: 0; +} + +.lp-hero-bg i:nth-child(3) { + left: calc(50% - 28px - 448px); + top: 224px; +} + +.lp-hero-bg i:nth-child(4) { + left: calc(50% - 28px + 392px); + top: 112px; +} + +.lp-hero-bg i:nth-child(5) { + left: calc(50% - 28px - 168px); + top: 392px; + background: rgba(99, 102, 241, 0.16); +} + +.lp-hero-bg i:nth-child(6) { + left: calc(50% - 28px + 168px); + top: 448px; +} + +.lp-hero-bg i:nth-child(7) { + left: calc(50% - 28px + 504px); + top: 336px; +} + +.lp-hero-bg i:nth-child(8) { + left: calc(50% - 28px - 560px); + top: 392px; +} + +.lp-hero-bg i:nth-child(9) { + left: calc(50% - 28px - 56px); + top: 0; + background: rgba(255, 255, 255, 0.025); +} + +.lp-hero-bg i:nth-child(10) { + left: calc(50% - 28px + 56px); + top: 504px; +} + +.lp-hero-bg i:nth-child(11) { + left: calc(50% - 28px - 280px); + top: 504px; +} + +.lp-hero-bg i:nth-child(12) { + left: calc(50% - 28px + 616px); + top: 56px; +} + +.lp-hero-content { + position: relative; + max-width: 920px; + margin: 0 auto; +} + +.lp-sub { + max-width: 560px; + margin: 26px auto 0; + font-size: clamp(16px, 1.6vw, 19px); + line-height: 1.55; + color: var(--lp-text-2); +} + +.lp-checks { + display: flex; + flex-wrap: wrap; + justify-content: center; + margin-top: 26px; +} + +.lp-check { + position: relative; + display: inline-flex; + align-items: center; + gap: 8px; + padding: 6px 18px; + font-size: 14px; + font-weight: 500; + color: var(--lp-text-2); +} + +.lp-check + .lp-check::before { + content: ''; + position: absolute; + left: 0; + top: 50%; + width: 1px; + height: 16px; + background: var(--lp-line-2); + transform: translateY(-50%); +} + +.lp-check svg { + width: 15px; + height: 15px; + color: var(--lp-accent-2); +} + +.lp-check:nth-child(2) svg { + color: #6ee7b7; +} + +.lp-check:nth-child(3) svg { + color: #fcd34d; +} + +@media (max-width: 639px) { + .lp-check + .lp-check::before { + display: none; + } +} + +.lp-actions { + display: flex; + flex-wrap: wrap; + align-items: center; + justify-content: center; + gap: 12px; + margin-top: 40px; +} + +@keyframes lp-rise { + from { + opacity: 0; + transform: translateY(16px); + } + + to { + opacity: 1; + transform: none; + } +} + +.lp-hero-content > * { + animation: lp-rise 0.8s cubic-bezier(0.2, 0.7, 0.2, 1) both; +} + +.lp-hero-content > :nth-child(2) { + animation-delay: 0.08s; +} + +.lp-hero-content > :nth-child(3) { + animation-delay: 0.16s; +} + +.lp-hero-content > :nth-child(4) { + animation-delay: 0.24s; +} + +.lp-hero-content > :nth-child(5) { + animation-delay: 0.32s; +} + +.lp-logos { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 1px; + background: var(--lp-line); + border-top: 1px solid var(--lp-line); +} + +.lp-logos-label { + grid-column: 1 / -1; + display: flex; + align-items: center; + margin: 0; + padding: 20px var(--lp-pad); + background: var(--lp-bg-2); + font-size: 14px; + font-weight: 500; + line-height: 1.4; + color: var(--lp-text); +} + +.lp-logo { + display: flex; + align-items: center; + justify-content: center; + min-height: 88px; + background: var(--lp-bg); + color: #8e8e97; + transition: + color 0.25s, + background-color 0.25s; +} + +.lp-logo:hover { + color: var(--lp-text); + background: var(--lp-bg-2); +} + +.lp-logo svg { + height: 26px; + width: auto; +} + +@media (min-width: 640px) { + .lp-logos { + grid-template-columns: repeat(3, 1fr); + } +} + +@media (min-width: 900px) { + .lp-logos { + grid-template-columns: 1.7fr repeat(6, 1fr); + } + + .lp-logos-label { + grid-column: auto; + } +} + +.lp-grid { + position: relative; + display: grid; + gap: 1px; + background: var(--lp-line); +} + +.lp-cell { + position: relative; + display: flex; + flex-direction: column; + gap: 8px; + min-height: 250px; + padding: 30px var(--lp-pad) 34px; + background: var(--lp-bg-2); + transition: background-color 0.25s; +} + +.lp-cell:hover { + background-color: var(--lp-bg-3); +} + +.lp-band .lp-cell { + background: var(--lp-bg-2); +} + +.lp-band .lp-cell:hover { + background-color: var(--lp-bg-3); +} + +.lp-cell h3 { + margin: 32px 0 0; + font-family: var(--lp-display); + font-size: 20px; + font-weight: 600; + letter-spacing: -0.015em; + color: var(--lp-text); +} + +.lp-cell p { + max-width: 34ch; + margin: 0; + font-size: 15px; + line-height: 1.55; + color: var(--lp-text-2); +} + +.lp-tile { + display: grid; + place-items: center; + width: 44px; + height: 44px; + border: 1px solid var(--lp-line-2); + border-radius: 6px; + background: var(--lp-bg-3); + color: var(--lp-accent-2); + transition: + background-color 0.25s, + border-color 0.25s, + color 0.25s; +} + +.lp-tile svg { + width: 20px; + height: 20px; +} + +.lp-cell:hover .lp-tile { + background: var(--lp-accent); + border-color: transparent; + color: #fff; +} + +.lp-cell--raised, +.lp-band .lp-cell--raised { + background: var(--lp-bg-3); +} + +.lp-cell--raised:hover { + background-color: #3e3e40; +} + +.lp-cell--hatch { + background-image: repeating-linear-gradient( + 135deg, + rgba(255, 255, 255, 0.06) 0 1px, + transparent 1px 10px + ); +} + +.lp-cell--dots { + background-image: radial-gradient( + rgba(255, 255, 255, 0.16) 1px, + transparent 1.5px + ); + background-size: 16px 16px; +} + +.lp-cell--grid { + background-image: + linear-gradient(to right, var(--lp-line) 1px, transparent 1px), + linear-gradient(to bottom, var(--lp-line) 1px, transparent 1px); + background-size: 28px 28px; +} + +.lp-cell--accent, +.lp-cell--accent:hover, +.lp-band .lp-cell--accent { + background: var(--lp-accent); +} + +.lp-cell--accent h3, +.lp-cell--accent b { + color: #fff; +} + +.lp-cell--accent p, +.lp-cell--accent span { + color: rgba(255, 255, 255, 0.82); +} + +.lp-cell--white, +.lp-band .lp-cell--white { + background: var(--lp-white); +} + +.lp-cell--white:hover { + background-color: #fff; +} + +.lp-cell--white h3, +.lp-cell--white b { + color: #09090b; +} + +.lp-cell--white p, +.lp-cell--white span { + color: #52525b; +} + +.lp-num-wrap { + align-self: flex-start; + padding: 5px; +} + +.lp-num { + display: inline-grid; + place-items: center; + width: 40px; + height: 40px; + border-radius: 3px; + background: var(--lp-text); + font-family: var(--lp-mono); + font-size: 14px; + font-weight: 700; + color: var(--lp-bg); +} + +.lp-cell--accent .lp-num { + background: #fff; + color: var(--lp-accent); +} + +.lp-cell--white .lp-num { + background: #09090b; + color: #fff; +} + +.lp-badge { + width: 60px; + height: 60px; + border-radius: 50%; + overflow: hidden; + box-shadow: 0 0 0 1px var(--lp-line-2); +} + +.lp-badge img { + display: block; + width: 100%; + height: 100%; +} + +.lp-stat { + align-items: center; + justify-content: center; + min-height: 170px; + padding: 28px var(--lp-pad); + text-align: center; +} + +.lp-stat b { + font-family: var(--lp-display); + font-size: clamp(38px, 4.5vw, 54px); + font-weight: 700; + line-height: 1; + letter-spacing: -0.045em; + color: var(--lp-text); +} + +.lp-stat span { + margin-top: 12px; + font-size: 14px; + font-weight: 500; + color: var(--lp-text-2); +} + +.lp-quote-cell { + justify-content: flex-start; + min-height: 0; + margin: 0; + padding: 30px var(--lp-pad) 32px; +} + +.lp-stars { + margin-bottom: 18px; + font-size: 14px; + letter-spacing: 2px; + color: var(--lp-accent-2); +} + +.lp-quote { + margin: 0 0 22px; + font-size: 15.5px; + line-height: 1.6; + color: var(--lp-text); +} + +.lp-quote::before { + content: '\201C'; + display: block; + height: 26px; + margin-bottom: 8px; + font-family: var(--lp-display); + font-size: 48px; + font-weight: 700; + line-height: 1; + color: var(--lp-accent-2); +} + +.lp-cite { + display: flex; + flex-direction: column; + align-items: flex-start; + gap: 2px; + margin-top: auto; + font-size: 13px; + font-weight: 500; + font-style: normal; + color: var(--lp-text-2); +} + +.lp-cite a { + font-size: 14px; + color: var(--lp-text); + transition: color 0.2s; +} + +.lp-cite a:hover { + color: var(--lp-accent-3); +} + +.lp-cite span { + font-weight: 400; + color: var(--lp-dim); +} + +@media (min-width: 640px) { + .lp-grid--2, + .lp-grid--3, + .lp-grid--4 { + grid-template-columns: repeat(2, 1fr); + } +} + +@media (min-width: 900px) { + .lp-grid--3 { + grid-template-columns: repeat(3, 1fr); + } + + .lp-grid--4 { + grid-template-columns: repeat(4, 1fr); + } +} + +.lp-tools-body { + padding: 0 var(--lp-pad) clamp(56px, 7vw, 96px); +} + +.lp-search { + position: relative; + max-width: 720px; + margin: 0 auto 40px; +} + +.lp-search-icon { + position: absolute; + left: 16px; + top: 50%; + transform: translateY(-50%); + color: var(--lp-text-2); + pointer-events: none; +} + +.lp-search-icon svg { + width: 18px; + height: 18px; +} + +.lp-input { + width: 100%; + height: 56px; + padding: 0 150px 0 46px; + border: 1px solid var(--lp-line-2); + border-radius: 6px; + background: var(--lp-well); + font-family: inherit; + font-size: 15px; + color: var(--lp-text); + outline: none; + transition: + border-color 0.2s, + box-shadow 0.2s; +} + +.lp-input::placeholder { + color: #71717a; +} + +.lp-input:focus { + border-color: var(--lp-accent); + box-shadow: 0 0 0 4px rgba(99, 102, 241, 0.18); +} + +.lp-search-right { + position: absolute; + right: 8px; + top: 50%; + display: flex; + align-items: center; + gap: 6px; + transform: translateY(-50%); +} + +.lp-kbd { + padding: 6px 8px; + border: 1px solid var(--lp-line-2); + border-radius: 4px; + background: var(--lp-bg); + font-family: var(--lp-mono); + font-size: 11px; + font-weight: 600; + letter-spacing: 0.04em; + color: var(--lp-text-2); +} + +.lp-kbd:empty { + display: none; +} + +.lp-icon-btn { + display: inline-grid; + place-items: center; + width: 34px; + height: 34px; + border-radius: 4px; + color: var(--lp-text-2); + transition: + color 0.2s, + background-color 0.2s; +} + +.lp-icon-btn:hover { + color: var(--lp-text); + background: rgba(255, 255, 255, 0.06); +} + +.lp-icon-btn svg { + width: 16px; + height: 16px; +} + +@media (max-width: 480px) { + .lp-input { + padding-right: 56px; + } + + .lp-search:has(.lp-kbd:not(:empty)) .lp-input { + padding-right: 130px; + } +} + +.lp #tool-grid { + --tile-fg: #a5b4fc; +} + +.lp #tool-grid > .is-cat-edit-annotate { + --tile-fg: #d6c285; +} + +.lp #tool-grid > .is-cat-convert-to-pdf { + --tile-fg: #79c4cb; +} + +.lp #tool-grid > .is-cat-convert-from-pdf { + --tile-fg: #b8aade; +} + +.lp #tool-grid > .is-cat-organize-manage { + --tile-fg: #8dacda; +} + +.lp #tool-grid > .is-cat-optimize-repair { + --tile-fg: #89cca8; +} + +.lp #tool-grid > .is-cat-secure-pdf { + --tile-fg: #dea3a3; +} + +.lp .category-group { + margin-top: 56px; +} + +.lp .category-group:first-child, +.lp #search-results + .category-group { + margin-top: 0; +} + +.lp .category-header { + justify-content: flex-start; + gap: 14px; + margin: 0 0 22px; + padding: 12px 2px; + border-bottom: 1px solid rgba(255, 255, 255, 0.07); + font-family: var(--lp-display); + font-size: 20px; + font-weight: 600; + letter-spacing: -0.01em; + color: var(--lp-text); +} + +.lp .category-header > span { + margin-right: auto; +} + +.lp .category-header:hover, +.lp .category-header:hover .category-chevron { + color: var(--lp-accent-3); +} + +.lp .category-chevron { + color: var(--lp-text-2); +} + +.lp #tool-grid:not(.compact-mode) .category-tools, +.lp #tool-grid:not(.compact-mode) #search-results { + grid-template-columns: minmax(0, 1fr); + gap: 1px; + border: 1px solid var(--lp-line-2); + background-color: var(--lp-well); + background-image: repeating-linear-gradient( + 135deg, + rgba(255, 255, 255, 0.05) 0 1px, + transparent 1px 10px + ); +} + +.lp #tool-grid .category-group.collapsed .category-tools { + border-width: 0; +} + +@media (min-width: 520px) { + .lp #tool-grid:not(.compact-mode) .category-tools, + .lp #tool-grid:not(.compact-mode) #search-results { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } +} + +@media (min-width: 800px) { + .lp #tool-grid:not(.compact-mode) .category-tools, + .lp #tool-grid:not(.compact-mode) #search-results { + grid-template-columns: repeat(3, minmax(0, 1fr)); + } +} + +@media (min-width: 1024px) { + .lp #tool-grid:not(.compact-mode) .category-tools, + .lp #tool-grid:not(.compact-mode) #search-results { + grid-template-columns: repeat(4, minmax(0, 1fr)); + } +} + +.lp #tool-grid:not(.compact-mode) .tool-card { + display: flex; + flex-direction: column; + align-items: flex-start; + justify-content: flex-start; + min-height: 190px; + padding: 22px 22px 24px; + border: 0; + border-radius: 0; + background: var(--lp-bg-2); + outline: 1px solid var(--lp-line); + text-align: left; + box-shadow: none; + transition: background-color 0.25s; +} + +.lp #tool-grid:not(.compact-mode) .tool-card:focus-visible { + z-index: 1; + outline: 2px solid var(--lp-accent-3); + outline-offset: -2px; +} + +.lp #tool-grid:not(.compact-mode) .tool-card:hover { + background: var(--lp-bg-3); + transform: none; + box-shadow: none; +} + +.lp #tool-grid:not(.compact-mode) .tool-card > svg, +.lp #tool-grid:not(.compact-mode) .tool-card > i.ph { + display: grid; + place-items: center; + width: 32px; + height: 32px; + margin: 0; + padding: 0; + border: 0; + background: none; + font-size: 30px; + line-height: 1; + color: var(--tile-fg); + transition: color 0.25s; +} + +.lp #tool-grid:not(.compact-mode) .tool-card > i.ph { + padding: 0; +} + +.lp #tool-grid:not(.compact-mode) .tool-card h3 { + margin: 22px 0 0; + font-family: var(--lp-display); + font-size: 16px; + font-weight: 600; + line-height: 1.3; + letter-spacing: -0.01em; + color: var(--lp-text); +} + +.lp #tool-grid:not(.compact-mode) .tool-card p { + display: -webkit-box; + -webkit-box-orient: vertical; + -webkit-line-clamp: 3; + overflow: hidden; + margin: 7px 0 0; + padding: 0; + font-size: 13.5px; + line-height: 1.5; + color: var(--lp-text-2); +} + +.lp .compact-mode .tool-card { + border-bottom-color: var(--lp-line); +} + +.lp .compact-mode .tool-card:hover { + background: var(--lp-bg-2); + border-color: var(--lp-line); +} + +.lp-faq-wrap { + padding: 0 var(--lp-pad) clamp(56px, 7vw, 96px); +} + +.lp-faq { + position: relative; + max-width: 820px; + margin: 0 auto; + border: 1px solid var(--lp-line); +} + +.lp-faq .faq-item { + border-bottom: 1px solid var(--lp-line); + background: var(--lp-bg-2); +} + +.lp-faq .faq-item:last-child { + border-bottom: 0; +} + +.lp-faq .faq-question { + gap: 20px; + padding: 22px 24px; + font-family: var(--lp-display); + font-size: 18px; + font-weight: 600; + letter-spacing: -0.01em; + color: var(--lp-text); + transition: + color 0.2s, + background-color 0.2s; +} + +.lp-faq .faq-question:hover { + background: var(--lp-bg-3); +} + +.lp-faq .faq-icon { + flex-shrink: 0; + width: 20px; + height: 20px; + color: var(--lp-text-2); + transition: + transform 0.3s, + color 0.2s; +} + +.lp-faq .faq-item.open .faq-icon { + transform: rotate(45deg); + color: var(--lp-accent-2); +} + +.lp-faq .faq-item.open .faq-question { + color: var(--lp-accent-3); +} + +.lp-faq .faq-answer p { + margin: 0; + padding: 0 24px 24px; + font-size: 15px; + line-height: 1.65; + color: var(--lp-text-2); +} + +.lp-cta { + position: relative; + overflow: hidden; + padding: clamp(72px, 10vw, 128px) var(--lp-pad); + text-align: center; +} + +.lp-cta::before { + content: ''; + position: absolute; + inset: 0; + pointer-events: none; + background: radial-gradient( + 60% 70% at 50% 110%, + rgba(99, 102, 241, 0.28), + transparent 70% + ); +} + +.lp-cta > * { + position: relative; +} + +.lp-cta .lp-h2 { + max-width: 760px; + margin-inline: auto; +} + +.lp-cta p { + max-width: 560px; + margin: 20px auto 0; + font-size: 16px; + line-height: 1.6; + color: var(--lp-text-2); +} + +.lp-cta-actions { + display: flex; + flex-wrap: wrap; + align-items: center; + justify-content: center; + gap: 12px; + margin-top: 36px; +} + +.lp-footer { + border-top: 1px solid var(--lp-line); +} + +.lp-footer-inner { + border-left: 1px solid var(--lp-line); + border-right: 1px solid var(--lp-line); +} + +.lp-footer-grid { + display: grid; + grid-template-columns: 1fr; + gap: 1px; + background: var(--lp-line); +} + +.lp-footer-grid > div { + padding: 36px var(--lp-pad); + background: var(--lp-bg); +} + +.lp-footer h3 { + margin: 0 0 18px; + font-size: 13px; + font-weight: 600; + color: var(--lp-text-2); +} + +.lp-footer ul { + display: grid; + gap: 10px; + margin: 0; + padding: 0; + list-style: none; +} + +.lp-footer ul a { + font-size: 14px; + color: var(--lp-text); + transition: color 0.2s; +} + +.lp-footer ul a:hover { + color: var(--lp-accent-3); +} + +.lp-footer-brand { + display: flex; + align-items: center; + gap: 10px; + font-family: var(--lp-display); + font-size: 19px; + font-weight: 700; + letter-spacing: -0.02em; + color: var(--lp-text); +} + +.lp-footer-brand img { + width: 32px; + height: 32px; +} + +.lp-footer-copy { + margin: 16px 0 0; + font-size: 13px; + color: var(--lp-text-2); +} + +.lp-footer-version { + margin: 6px 0 0; + font-family: var(--lp-mono); + font-size: 11px; + letter-spacing: 0.08em; + color: var(--lp-dim); +} + +.lp-social { + display: flex; + align-items: center; +} + +.lp-social a { + display: inline-flex; + color: var(--lp-text-2); + transition: color 0.2s; +} + +.lp-social a:hover { + color: var(--lp-text); +} + +.lp-social svg { + width: 22px; + height: 22px; +} + +.lp-footer .relative.w-full > button { + border-color: var(--lp-line-2); + border-radius: 4px; + background: var(--lp-bg-2); + color: var(--lp-text-2); +} + +.lp-footer .relative.w-full > button:hover { + color: var(--lp-text); +} + +.lp-footer-bottom { + display: flex; + flex-wrap: wrap; + align-items: center; + justify-content: space-between; + gap: 12px; + padding: 18px var(--lp-pad); + border-top: 1px solid var(--lp-line); + font-size: 12px; + color: var(--lp-dim); +} + +.lp-footer-bottom a { + opacity: 0.6; + transition: opacity 0.2s; +} + +.lp-footer-bottom a:hover { + opacity: 1; +} + +@media (min-width: 768px) { + .lp-footer-grid { + grid-template-columns: 1.5fr 1fr 1fr 1.2fr; + } +} + +.lp #scroll-to-top-btn { + border: 1px solid var(--lp-line-2); + border-radius: 6px; + background: var(--lp-bg-3); + color: var(--lp-text); + box-shadow: 0 12px 30px -10px rgba(0, 0, 0, 0.8); +} + +.lp #scroll-to-top-btn:hover { + background: var(--lp-accent); + border-color: transparent; +} + +.lp-js .lp-reveal { + opacity: 0; + transform: translateY(20px); + transition: + opacity 0.7s ease, + transform 0.8s cubic-bezier(0.2, 0.7, 0.2, 1); + transition-delay: var(--lp-delay, 0ms); +} + +.lp-js .lp-reveal.is-in { + opacity: 1; + transform: none; +} + +.lp-grid > .lp-reveal:nth-child(2) { + --lp-delay: 70ms; +} + +.lp-grid > .lp-reveal:nth-child(3) { + --lp-delay: 140ms; +} + +.lp-grid > .lp-reveal:nth-child(4) { + --lp-delay: 210ms; +} + +.lp-grid > .lp-reveal:nth-child(5) { + --lp-delay: 280ms; +} + +.lp-grid > .lp-reveal:nth-child(6) { + --lp-delay: 350ms; +} + +@media (prefers-reduced-motion: reduce) { + .lp-hero-content > * { + animation: none; + } + + .lp-js .lp-reveal { + opacity: 1; + transform: none; + transition: none; + } + + .lp-btn, + .lp-btn svg { + transition: none; + } +} + +.lp-grid + .lp-grid { + border-top: 0; +} + +.lp-quote-cell p { + max-width: none; +} + +.lp-stat.lp-cell--white b { + color: #09090b; +} + +.lp-stat.lp-cell--white span { + color: #52525b; +} + +.lp-stat.lp-cell--accent span { + color: rgba(255, 255, 255, 0.85); +} + +.lp-manifesto { + display: grid; + gap: 28px; + justify-items: center; + margin: 0; + padding: clamp(56px, 7vw, 80px) var(--lp-pad) clamp(8px, 2vw, 24px); + text-align: center; +} + +.lp-manifesto .lp-h2 { + margin-top: 0; +} + +.lp-manifesto-text { + margin: 0; + font-family: var(--lp-display); + font-size: clamp(22px, 2.6vw, 34px); + font-weight: 500; + line-height: 1.3; + letter-spacing: -0.02em; + color: var(--lp-text); + text-wrap: pretty; +} + +.lp-manifesto-text::before { + content: '\201C'; + margin-right: 0.08em; + color: var(--lp-accent-2); +} + +.lp-manifesto-text::after { + content: '\201D'; + margin-left: 0.08em; + color: var(--lp-accent-2); +} + +.lp-story { + display: grid; + gap: 32px; + max-width: calc(720px + 2 * var(--lp-pad)); + margin: 0 auto; + padding: clamp(64px, 9vw, 112px) var(--lp-pad) clamp(48px, 6vw, 80px); +} + +.lp-story .lp-h2 { + margin-top: 0; +} + +.lp .lp-story .lp-h2 .line { + display: block; +} + +.lp-story-by { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 16px; + margin-top: 28px; + font-size: 14px; + font-weight: 500; +} + +.lp-avatar { + width: 44px; + height: 44px; + border-radius: 50%; + box-shadow: 0 0 0 1px var(--lp-line-2); +} + +.lp-story-body { + display: grid; + gap: 20px; + max-width: 720px; +} + +.lp-story-body p { + margin: 0; + font-size: 17px; + line-height: 1.7; + color: var(--lp-text-2); +} + +.lp-story-outro { + display: grid; + gap: 14px; + max-width: calc(720px + 2 * var(--lp-pad)); + margin: 0 auto; + padding: clamp(48px, 6vw, 72px) var(--lp-pad) clamp(24px, 3vw, 40px); + text-align: left; +} + +.lp-story-outro p { + margin: 0; + font-size: 17px; + line-height: 1.7; + color: var(--lp-text-2); + max-width: 720px; +} + +.lp-stat-link span { + display: inline-flex; + align-items: center; + gap: 4px; +} + +.lp-stat-link svg { + width: 14px; + height: 14px; + transition: transform 0.2s; +} + +.lp .lp-cell--raised.lp-stat-link:hover { + background-color: #3e3e40; +} + +.lp-stat-link:hover span { + color: var(--lp-text); +} + +.lp-stat-link:hover svg { + transform: translate(2px, -2px); +} + +.lp-story-by a, +.lp-story-body a { + color: var(--lp-accent-3); + text-decoration: underline; + text-decoration-color: rgba(165, 180, 252, 0.4); + text-underline-offset: 4px; + transition: + color 0.2s, + text-decoration-color 0.2s; +} + +.lp-story-by a:hover, +.lp-story-body a:hover { + color: var(--lp-text); + text-decoration-color: currentColor; +} + +.lp #shortcuts-modal { + background: rgba(0, 0, 0, 0.45); + -webkit-backdrop-filter: blur(4px); + backdrop-filter: blur(4px); +} + +.lp #shortcuts-modal > div { + border: 1px solid rgba(255, 255, 255, 0.1); + border-radius: 12px; + background: rgb(45, 45, 45); + box-shadow: + 0 0 0 0.5px rgba(0, 0, 0, 0.12), + 0 24px 60px rgba(0, 0, 0, 0.35), + 0 8px 20px rgba(0, 0, 0, 0.2); +} + +.lp #shortcuts-modal > div > div:first-child { + padding: 16px 20px; + border-bottom: 1px solid rgba(255, 255, 255, 0.06); +} + +.lp #shortcuts-modal h3 { + font-size: 16px; + font-weight: 600; + letter-spacing: -0.01em; +} + +.lp #shortcuts-modal h3 svg { + width: 18px; + height: 18px; + color: var(--lp-accent-2); +} + +.lp #close-shortcuts-modal { + display: grid; + place-items: center; + width: 34px; + height: 34px; + border-radius: 8px; + color: var(--lp-text-2); + transition: + background-color 0.15s, + color 0.15s; +} + +.lp #close-shortcuts-modal:hover { + background: rgba(255, 255, 255, 0.11); + color: var(--lp-text); +} + +.lp #close-shortcuts-modal svg { + width: 18px; + height: 18px; +} + +.lp #shortcuts-modal div:has(> #shortcuts-tab-btn) { + gap: 4px; + margin: 14px 20px 0; + padding: 4px; + border: 1px solid rgba(255, 255, 255, 0.06); + border-bottom: 1px solid rgba(255, 255, 255, 0.06); + border-radius: 10px; + background: rgb(34, 34, 34); +} + +.lp #shortcuts-tab-btn, +.lp #preferences-tab-btn { + padding: 8px 0; + border-radius: 7px; + font-size: 13px; + font-weight: 600; + color: var(--lp-text-2); + transition: + background-color 0.15s, + color 0.15s; +} + +.lp #shortcuts-tab-btn:hover, +.lp #preferences-tab-btn:hover { + color: var(--lp-text); +} + +.lp #shortcuts-tab-btn.bg-indigo-600, +.lp #preferences-tab-btn.bg-indigo-600 { + background: var(--lp-accent); + color: #fff; +} + +.lp #shortcuts-tab-btn svg, +.lp #preferences-tab-btn svg { + width: 14px; + height: 14px; + vertical-align: -2px; +} + +.lp #shortcuts-tab-content, +.lp #preferences-tab-content { + padding: 18px 20px; +} + +.lp #shortcut-search { + height: 38px; + border: 1px solid var(--lp-line-2); + border-radius: 8px; + background: rgb(34, 34, 34); + font-size: 13.5px; + color: var(--lp-text); + transition: + border-color 0.15s, + box-shadow 0.15s; +} + +.lp #shortcut-search:focus { + border-color: var(--lp-accent); + box-shadow: 0 0 0 3px rgba(99, 102, 241, 0.18); + outline: none; +} + +.lp #shortcut-search::placeholder { + color: var(--lp-dim); +} + +.lp #shortcuts-tab-content > div:nth-child(2) { + padding: 12px 14px; + border: 1px solid rgba(129, 140, 248, 0.25); + border-radius: 10px; + background: rgba(99, 102, 241, 0.08); +} + +.lp #shortcuts-tab-content > div:nth-child(2) p { + font-size: 12.5px; + line-height: 1.55; + color: var(--lp-accent-3); +} + +.lp #shortcuts-tab-content > div:nth-child(2) .text-yellow-300 { + color: #fcd34d; +} + +.lp #shortcuts-list .category-section h3 { + margin-bottom: 10px; + font-size: 11px; + font-weight: 600; + letter-spacing: 0.06em; + color: var(--lp-dim); +} + +.lp #shortcuts-list .shortcut-item { + padding: 10px 12px; + border: 1px solid rgba(255, 255, 255, 0.06); + border-radius: 10px; + background: rgb(34, 34, 34); + transition: + background-color 0.15s, + border-color 0.15s; +} + +.lp #shortcuts-list .shortcut-item:hover { + background: rgba(255, 255, 255, 0.11); + border-color: rgba(255, 255, 255, 0.06); +} + +.lp #shortcuts-list .shortcut-item i.ph { + font-size: 18px; + line-height: 1; + color: var(--lp-accent-2); +} + +.lp #shortcuts-list .shortcut-item span { + font-size: 13px; + font-weight: 500; + color: var(--lp-text); +} + +.lp #shortcuts-list .shortcut-input { + height: 30px; + border: 1px solid var(--lp-line-2); + border-radius: 6px; + background: rgb(45, 45, 45); + font-size: 12px; + color: var(--lp-text); +} + +.lp #shortcuts-list .shortcut-input:focus { + border-color: var(--lp-accent); + box-shadow: 0 0 0 3px rgba(99, 102, 241, 0.18); +} + +.lp #preferences-tab-content h4 { + margin-bottom: 14px; + font-size: 13px; + font-weight: 600; + color: var(--lp-text); +} + +.lp #preferences-tab-content .bg-gray-900 { + padding: 14px 16px; + border: 1px solid rgba(255, 255, 255, 0.06); + border-radius: 10px; + background: rgb(34, 34, 34); +} + +.lp #preferences-tab-content .bg-gray-900 label:first-child, +.lp #preferences-tab-content a .text-gray-200 { + font-size: 13.5px; + font-weight: 600; + color: var(--lp-text); +} + +.lp #preferences-tab-content .text-xs { + font-size: 12px; + color: var(--lp-dim); +} + +.lp #preferences-tab-content a.bg-gray-900:hover { + background: rgba(255, 255, 255, 0.11); + border-color: rgba(129, 140, 248, 0.4); +} + +.lp #preferences-tab-content a.bg-gray-900:hover svg { + color: var(--lp-accent-2); +} + +.lp #shortcuts-modal input.peer:not(:checked) + div { + background-color: #3d3d3f; +} + +.lp #shortcuts-modal input.peer:checked + div { + background-color: var(--lp-accent); +} + +.lp #shortcuts-modal > div > div:last-child { + padding: 14px 20px; + border-top: 1px solid rgba(255, 255, 255, 0.06); + border-radius: 0 0 14px 14px; + background: transparent; +} + +.lp #import-shortcuts-btn, +.lp #export-shortcuts-btn { + height: 32px; + padding: 0 12px; + border: 1px solid var(--lp-line-2); + border-radius: 7px; + font-size: 12.5px; + font-weight: 600; + color: var(--lp-text-2); + transition: + background-color 0.15s, + color 0.15s; +} + +.lp #import-shortcuts-btn:hover, +.lp #export-shortcuts-btn:hover { + background: rgba(255, 255, 255, 0.08); + color: var(--lp-text); +} + +.lp #import-shortcuts-btn svg, +.lp #export-shortcuts-btn svg { + width: 14px; + height: 14px; +} + +.lp #reset-shortcuts-btn { + height: 32px; + padding: 0 12px; + border: 1px solid rgba(244, 63, 94, 0.35); + border-radius: 7px; + font-size: 12.5px; + font-weight: 600; + color: #fda4af; + transition: + background-color 0.15s, + color 0.15s; +} + +.lp #reset-shortcuts-btn:hover { + background: rgba(244, 63, 94, 0.14); + color: #fecdd3; +} + +.lp-cell.lp-stat:hover, +.lp-band .lp-cell.lp-stat:hover, +.lp-cell.lp-tier:hover, +.lp-band .lp-cell.lp-tier:hover { + background-color: var(--lp-bg-2); +} + +.lp-cell--hatch.lp-stat:hover, +.lp-band .lp-cell--hatch.lp-stat:hover, +.lp-cell--hatch.lp-tier:hover, +.lp-band .lp-cell--hatch.lp-tier:hover { + background-color: var(--lp-bg-2); +} + +.lp-cell--raised.lp-stat:hover, +.lp-band .lp-cell--raised.lp-stat:hover, +.lp-cell--raised.lp-tier:hover, +.lp-band .lp-cell--raised.lp-tier:hover { + background-color: var(--lp-bg-3); +} + +.lp-cell--white.lp-stat:hover, +.lp-band .lp-cell--white.lp-stat:hover, +.lp-cell--white.lp-tier:hover, +.lp-band .lp-cell--white.lp-tier:hover { + background-color: var(--lp-white); +} + +.lp-cell--accent.lp-stat:hover, +.lp-band .lp-cell--accent.lp-stat:hover, +.lp-cell--accent.lp-tier:hover, +.lp-band .lp-cell--accent.lp-tier:hover { + background-color: var(--lp-accent); +} + +.lp-h1-unit { + white-space: nowrap; +} + +@media (max-width: 639px) { + .lp-h1-lead { + display: block; + } +} diff --git a/src/css/styles.css b/src/css/styles.css index ed4111b6..cd1a7796 100644 --- a/src/css/styles.css +++ b/src/css/styles.css @@ -25,6 +25,15 @@ --font-lato: 'Lato', sans-serif; --font-merriweather: 'Merriweather', serif; --font-cedarville: 'Cedarville Cursive', cursive; + --color-gray-900: rgb(34, 34, 34); + --color-gray-800: rgb(42, 42, 42); + --color-gray-700: rgb(56, 56, 56); + --color-gray-600: rgb(68, 68, 68); + --color-indigo-300: #a5b4fc; + --color-indigo-400: #818cf8; + --color-indigo-500: #6366f1; + --color-indigo-600: #6366f1; + --color-indigo-700: #4f46e5; } @layer base { @@ -50,7 +59,7 @@ html { body { font-family: 'DM Sans', sans-serif; - background-color: #111827; + background-color: rgb(34, 34, 34); color: #d1d5db; } @@ -58,8 +67,8 @@ body { transition: transform 0.2s ease-in-out, box-shadow 0.2s ease-in-out; - border: 1px solid #374151; - background-color: #1f2937; + border: 1px solid #383838; + background-color: #2a2a2a; padding: 1.5rem; border-radius: 0.5rem; display: flex; @@ -73,7 +82,7 @@ body { box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.2), 0 4px 6px -2px rgba(0, 0, 0, 0.1); - border-color: #4f46e5; + border-color: #6366f1; } .category-header { @@ -126,7 +135,7 @@ body { border-left: none; border-right: none; border-top: none; - border-bottom: 1px solid #1f2937; + border-bottom: 1px solid #2a2a2a; background-color: transparent; align-items: center; justify-content: flex-start; @@ -160,8 +169,8 @@ body { .compact-mode .tool-card:hover { transform: none; box-shadow: none; - background-color: #1f2937; - border-color: #1f2937; + background-color: #2a2a2a; + border-color: #2a2a2a; } @media (min-width: 640px) { @@ -191,29 +200,10 @@ input[type='file']::file-selector-button { @apply bg-indigo-600 text-white font-semibold py-2 px-4 rounded-lg cursor-pointer hover:bg-indigo-700 transition-colors duration-200 mr-4; } -/* Custom scrollbar */ -::-webkit-scrollbar { - width: 8px; -} - -::-webkit-scrollbar-track { - background: #1f2937; -} - -::-webkit-scrollbar-thumb { - background: #4f46e5; - border-radius: 4px; -} - -::-webkit-scrollbar-thumb:hover { - background: #4338ca; - /* indigo-700 */ -} - /* Style for drag-and-drop placeholder */ .sortable-ghost { opacity: 0.4; - border: 2px dashed #4f46e5; + border: 2px dashed #6366f1; } #embed-pdf-container > div { @@ -251,9 +241,9 @@ input[type='file']::file-selector-button { min-width: 0; overflow: auto; height: 75vh; - border: 2px solid #374151; + border: 2px solid #383838; border-radius: 0.5rem; - background-color: #1f2937; + background-color: #2a2a2a; } /* This rule ensures canvases in side-by-side panels display at their natural rendered size. */ @@ -285,7 +275,7 @@ footer a { } .pill { - background-color: #374151; + background-color: #383838; /* bg-gray-700 */ color: #d1d5db; /* text-gray-300 */ @@ -297,7 +287,7 @@ footer a { } .cta-button { - background-color: #4f46e5; + background-color: #6366f1; /* indigo-600 */ color: white; font-weight: 600; @@ -308,7 +298,7 @@ footer a { } .cta-button:hover { - background-color: #4338ca; + background-color: #4f46e5; /* indigo-700 */ } @@ -319,7 +309,7 @@ footer a { } .feature-card { - background-color: #1f2937; + background-color: #2a2a2a; /* bg-gray-800 */ padding: 24px; border-radius: 8px; @@ -338,22 +328,12 @@ footer a { .marker-slanted { position: relative; display: inline-block; -} - -.marker-slanted::before { - content: ''; - position: absolute; - left: 0; - right: 0; - bottom: 0; - height: 6px; - background: linear-gradient(120deg, #6366f1, #8b5cf6); - z-index: -1; + color: #8d8d89; } .section-divider { height: 1px; - background: linear-gradient(to right, transparent, #4d44f7, transparent); + background: linear-gradient(to right, transparent, #6366f1, transparent); /* Fades from transparent to indigo and back */ margin: 2rem auto; /* my-16 */ @@ -368,7 +348,7 @@ footer a { /* py-3 px-8 */ border-radius: 0.5rem; /* rounded-lg */ - background-image: linear-gradient(to bottom, #6366f1, #4f46e5); + background-image: linear-gradient(to bottom, #6366f1, #6366f1); /* from-indigo-500 to-indigo-600 */ color: #ffffff; /* text-white */ @@ -389,7 +369,7 @@ footer a { outline: 2px solid transparent; outline-offset: 2px; box-shadow: - 0 0 0 2px #111827, + 0 0 0 2px #222222, 0 0 0 4px #818cf8; /* focus:ring-2 focus:ring-offset-2 focus:ring-offset-gray-900 focus:ring-indigo-400 */ } @@ -406,9 +386,9 @@ footer a { /* w-16 */ height: 64px; /* h-16 */ - border: 5px solid #374151; + border: 5px solid #383838; /* border-gray-700 */ - border-bottom-color: #4f46e5; + border-bottom-color: #6366f1; /* border-indigo-600 */ border-radius: 50%; display: inline-block; @@ -438,7 +418,7 @@ footer a { /* Highlight for selected signature in the saved list */ .saved-signature.selected { - border-color: #4f46e5; + border-color: #6366f1; /* indigo-600 */ box-shadow: 0 0 10px rgba(79, 70, 229, 0.5); } @@ -475,19 +455,19 @@ footer a { /* Testimonial Card Styles */ .testimonial-card { - background-color: #1f2937; + background-color: #2a2a2a; /* bg-gray-800 */ padding: 24px; border-radius: 0.75rem; /* rounded-xl */ - border: 1px solid #374151; + border: 1px solid #383838; /* border-gray-700 */ display: flex; flex-direction: column; } .pill { - background-color: #374151; + background-color: #383838; /* bg-gray-700 */ color: #d1d5db; /* text-gray-300 */ @@ -499,7 +479,7 @@ footer a { } .pill { - background-color: #374151; + background-color: #383838; /* bg-gray-700 */ color: #d1d5db; /* text-gray-300 */ @@ -627,10 +607,10 @@ button:disabled, 'Courier New', monospace; line-height: 1; color: #e5e7eb; - background-color: #374151; - border: 1px solid #4b5563; + background-color: #383838; + border: 1px solid #444444; border-radius: 0.25rem; - box-shadow: 0 1px 0 0 #1f2937; + box-shadow: 0 1px 0 0 #2a2a2a; } .shortcut-input { @@ -652,11 +632,11 @@ button:disabled, } #shortcuts-list::-webkit-scrollbar-track { - background: #1f2937; + background: #2a2a2a; } #shortcuts-list::-webkit-scrollbar-thumb { - background: #4b5563; + background: #444444; border-radius: 3px; } @@ -693,7 +673,7 @@ input[type='color'] { width: 2.5rem; height: 2.5rem; padding: 0; - border: 2px solid #4b5563; + border: 2px solid #444444; border-radius: 9999px; background: transparent; cursor: pointer; @@ -726,3 +706,992 @@ input[type='number']::-webkit-outer-spin-button { -webkit-appearance: none; /* Chrome, Safari, Edge */ margin: 0; } + +/* Landing-system prose pages (legal, FAQ) */ +.lp .lp-prose { + max-width: 820px; + margin: 0 auto; + padding: clamp(48px, 6vw, 80px) var(--lp-pad); +} + +.lp .legal-content h2 { + font-family: var(--lp-display); + font-size: 22px; + line-height: 1.3; + font-weight: 600; + letter-spacing: -0.02em; + color: var(--lp-text); + margin-top: 2.75rem; + margin-bottom: 0.875rem; +} + +.lp .legal-content > h2:first-child { + margin-top: 0; +} + +.lp .legal-content h3 { + font-family: var(--lp-display); + font-size: 17px; + font-weight: 600; + letter-spacing: -0.01em; + color: var(--lp-accent-3); + margin-top: 1.75rem; + margin-bottom: 0.5rem; +} + +.lp .legal-content p, +.lp .legal-content ul { + font-size: 16px; + line-height: 1.7; + color: var(--lp-text-2); +} + +.lp .legal-content ul { + list-style-position: outside; + padding-left: 1.25rem; +} + +.lp .legal-content li { + margin-bottom: 0.375rem; +} + +.lp .legal-content strong { + color: var(--lp-text); + font-weight: 600; +} + +.lp .legal-content a { + color: var(--lp-accent-2); + text-decoration: underline; + text-underline-offset: 2px; +} + +.lp .legal-content a:hover { + color: var(--lp-accent-3); +} + +.lp .faq-answer > *:not(:last-child) { + padding-bottom: 8px; +} + +.lp .faq-answer ul { + margin: 0; + padding: 0 24px 24px 44px; + list-style: disc; + font-size: 15px; + line-height: 1.65; + color: var(--lp-text-2); +} + +.lp .faq-answer li { + margin-bottom: 4px; +} + +/* Landing-system content sections (licensing) */ +.lp .lp-pad { + padding: clamp(56px, 7vw, 96px) var(--lp-pad); +} + +.lp .lp-pad--tight { + padding: clamp(40px, 5vw, 64px) var(--lp-pad); +} + +.lp .lp-panel { + border: 1px solid var(--lp-line); + background: var(--lp-bg-2); + padding: clamp(24px, 3vw, 36px); +} + +.lp .lp-panel--accent { + border-color: var(--lp-accent); +} + +.lp .lp-h2--center { + text-align: center; + margin-inline: auto; +} + +.lp .lp-table { + width: 100%; + border-collapse: collapse; + border: 1px solid var(--lp-line); + background: var(--lp-bg-2); +} + +.lp .lp-table th { + font-family: var(--lp-display); + font-size: 14px; + font-weight: 600; + color: var(--lp-text); + border-bottom: 1px solid var(--lp-line); +} + +.lp .lp-table td { + font-size: 14px; + border-bottom: 1px solid var(--lp-line); + color: var(--lp-text-2); +} + +.lp .lp-table tr:last-child td { + border-bottom: 0; +} + +.lp .lp-panel-title { + font-family: var(--lp-display); + font-size: clamp(24px, 2.4vw, 30px); + font-weight: 700; + letter-spacing: -0.025em; + line-height: 1.1; + color: var(--lp-text); + margin: 0; +} + +.lp .lp-h2--center { + margin-bottom: 1rem; +} + +.lp tr.lp-thead { + background: var(--lp-bg-3); +} + +/* Advanced settings page (landing system) */ +.lp .ws-page { + min-height: 100vh; + padding: clamp(40px, 6vw, 72px) 0 clamp(56px, 7vw, 96px); +} + +.lp .ws-card { + width: min(760px, calc(100% - 2rem)); + margin: 0 auto; + border: 1px solid var(--lp-line); + background: var(--lp-bg-2); + padding: clamp(24px, 4vw, 40px); + color: var(--lp-text-2); +} + +.lp .ws-back { + display: inline-flex; + align-items: center; + gap: 8px; + margin-bottom: 28px; + font-size: 14px; + font-weight: 500; + color: var(--lp-text-2); + transition: color 0.2s; +} + +.lp .ws-back:hover { + color: var(--lp-text); +} + +.lp .ws-back svg { + width: 16px; + height: 16px; +} + +.lp .ws-title { + font-family: var(--lp-display); + font-size: clamp(26px, 3vw, 34px); + font-weight: 700; + letter-spacing: -0.03em; + line-height: 1.1; + color: var(--lp-text); + margin: 12px 0 10px; +} + +.lp .ws-sub { + font-size: 15px; + line-height: 1.6; + color: var(--lp-text-2); + margin: 0 0 28px; +} + +.lp .ws-module { + border: 1px solid var(--lp-line); + background: var(--lp-bg); + padding: 20px; +} + +.lp .ws-module h3 { + font-family: var(--lp-display); + font-size: 16px; + font-weight: 600; + color: var(--lp-text); +} + +.lp .ws-input { + border: 1px solid var(--lp-line-2); + border-radius: 6px; + background: var(--lp-bg-2); + color: var(--lp-text); + padding: 10px 12px; + font-size: 14px; + transition: border-color 0.2s; +} + +.lp .ws-input::placeholder { + color: var(--lp-dim); +} + +.lp .ws-input:focus { + outline: none; + border-color: var(--lp-accent); +} + +.lp .ws-code { + font-family: var(--lp-mono); + font-size: 12px; + color: var(--lp-accent-3); + background: var(--lp-bg); + border: 1px solid var(--lp-line); + border-radius: 4px; + padding: 6px 8px; +} + +.lp .ws-note { + border: 1px solid var(--lp-line); + background: var(--lp-bg); + padding: 16px; +} + +.lp .ws-icon-btn { + display: inline-grid; + place-items: center; + width: 30px; + height: 30px; + border: 1px solid var(--lp-line-2); + border-radius: 4px; + color: var(--lp-text-2); + transition: + background-color 0.2s, + color 0.2s; +} + +.lp .ws-icon-btn:hover { + background: var(--lp-bg-3); + color: var(--lp-text); +} + +/* ============================================================ + macOS-style motion system + ============================================================ */ + +:root { + /* Apple-ish easing curves */ + --ease-mac: cubic-bezier(0.32, 0.72, 0, 1); + --ease-out-expo: cubic-bezier(0.16, 1, 0.3, 1); + --ease-spring: cubic-bezier(0.34, 1.4, 0.64, 1); + --ease-standard: cubic-bezier(0.25, 0.1, 0.25, 1); + + --dur-1: 120ms; + --dur-2: 180ms; + --dur-3: 260ms; + --dur-4: 360ms; + --dur-5: 480ms; +} + +@keyframes mac-fade-in { + from { + opacity: 0; + } + + to { + opacity: 1; + } +} + +@keyframes mac-sheet-in { + from { + opacity: 0; + transform: scale(0.94) translateY(10px); + } + + to { + opacity: 1; + transform: none; + } +} + +@keyframes mac-drop-in { + from { + opacity: 0; + transform: translateY(-8px) scale(0.985); + } + + to { + opacity: 1; + transform: none; + } +} + +@keyframes mac-rise-in { + from { + opacity: 0; + transform: translateY(12px); + } + + to { + opacity: 1; + transform: none; + } +} + +/* ---- Dialogs: backdrop fade + sheet scale-in ---- */ + +#loader-modal, +#alert-modal, +#preview-modal, +#password-modal, +#shortcuts-modal, +#warning-modal { + animation: mac-fade-in var(--dur-3) var(--ease-mac) backwards; + backdrop-filter: blur(6px); + -webkit-backdrop-filter: blur(6px); +} + +#loader-modal > div, +#alert-modal > div, +#preview-modal > div, +#password-modal > div, +#shortcuts-modal > div, +#warning-modal > div, +.animate-scale-in { + animation: mac-sheet-in var(--dur-4) var(--ease-mac) backwards; + will-change: transform, opacity; +} + +/* ---- In-app view switching (tool grid <-> tool interface) ---- */ + +#tool-interface:not(.hidden) { + animation: mac-sheet-in var(--dur-4) var(--ease-mac) backwards; +} + +#grid-view:not(.hidden) { + animation: mac-fade-in var(--dur-3) var(--ease-standard) backwards; +} + +#tool-content > * { + animation: mac-rise-in var(--dur-4) var(--ease-out-expo) backwards; +} + +/* ---- Mobile menu ---- */ + +#mobile-menu:not(.hidden) { + animation: mac-drop-in var(--dur-3) var(--ease-mac) backwards; + transform-origin: top center; +} + +/* ---- Press feedback ---- */ + +button, +.btn, +.tool-card, +summary { + transition: + transform var(--dur-2) var(--ease-out-expo), + box-shadow var(--dur-2) var(--ease-out-expo), + background-color var(--dur-2) var(--ease-standard), + border-color var(--dur-2) var(--ease-standard), + color var(--dur-2) var(--ease-standard); +} + +button:not(:disabled):active, +.btn:not(:disabled):active, +.copy-btn:active { + transform: scale(0.97); + transition-duration: var(--dur-1); +} + +/* Full-width rows: scaling them reads as the layout lurching, not a press. */ +.faq-question:active, +.category-header:active, +summary:active, +.lp .faq-question:active, +.lp .category-header:active { + transform: none; +} + +.lp .lp-btn:not(:disabled):active, +.lp .lp-pill-btn:active { + transform: scale(0.97); + transition-duration: var(--dur-1); +} + +.lp .lp-btn--primary:not(:disabled):active { + transform: translateY(-1px) scale(0.97); +} + +.tool-card:active { + transform: translateY(-3px) scale(0.98); + transition-duration: var(--dur-1); +} + +.lp a.lp-cell:active { + transform: scale(0.995); + transition-duration: var(--dur-1); +} + +/* ---- Hover easing on interactive surfaces ---- */ + +.tool-card { + transition: + transform var(--dur-3) var(--ease-out-expo), + box-shadow var(--dur-3) var(--ease-out-expo), + border-color var(--dur-2) var(--ease-standard), + background-color var(--dur-2) var(--ease-standard); +} + +.lp .lp-cell, +.lp .lp-tile, +.lp .faq-question { + transition-timing-function: var(--ease-out-expo); + transition-duration: var(--dur-3); +} + +.faq-answer { + transition: + max-height var(--dur-4) var(--ease-mac), + opacity var(--dur-3) var(--ease-standard); +} + +.category-tools { + transition: max-height var(--dur-4) var(--ease-mac); +} + +.category-chevron, +.faq-icon, +details > summary .icon { + transition: transform var(--dur-3) var(--ease-spring); +} + +/* ---- Scroll-to-top: spring in ---- */ + +#scroll-to-top-btn { + transition: + opacity var(--dur-3) var(--ease-out-expo), + transform var(--dur-5) var(--ease-spring), + visibility var(--dur-3) var(--ease-standard), + background-color var(--dur-2) var(--ease-standard), + border-color var(--dur-2) var(--ease-standard); +} + +/* ---- Focus rings ---- */ + +:focus-visible { + transition: outline-offset var(--dur-1) var(--ease-out-expo); +} + +/* ---- Cross-document page transitions ---- */ + +@view-transition { + navigation: auto; +} + +::view-transition-old(root) { + animation: mac-fade-in var(--dur-2) var(--ease-standard) reverse both; +} + +::view-transition-new(root) { + animation: mac-fade-in var(--dur-3) var(--ease-standard) backwards; +} + +.lp .lp-nav { + view-transition-name: site-nav; +} + +.lp .lp-footer { + view-transition-name: site-footer; +} + +/* ---- macOS-style activity indicator ---- */ + +@supports (mask: radial-gradient(#000, #000)) or + (-webkit-mask: radial-gradient(#000, #000)) { + .solid-spinner { + border: none; + background: conic-gradient( + from 0turn, + rgba(99, 102, 241, 0) 0deg, + rgba(99, 102, 241, 0.15) 90deg, + rgba(99, 102, 241, 0.6) 240deg, + rgb(99, 102, 241) 350deg + ); + mask: radial-gradient( + farthest-side, + transparent calc(100% - 6px), + #000 calc(100% - 5px) + ); + -webkit-mask: radial-gradient( + farthest-side, + transparent calc(100% - 6px), + #000 calc(100% - 5px) + ); + animation: spin 0.9s var(--ease-standard) infinite; + } +} + +/* ---- Respect reduced motion ---- */ + +@media (prefers-reduced-motion: reduce) { + #loader-modal, + #alert-modal, + #preview-modal, + #password-modal, + #shortcuts-modal, + #warning-modal, + #loader-modal > div, + #alert-modal > div, + #preview-modal > div, + #password-modal > div, + #shortcuts-modal > div, + #warning-modal > div, + .animate-scale-in, + #tool-interface:not(.hidden), + #grid-view:not(.hidden), + #tool-content > *, + #mobile-menu:not(.hidden) { + animation: none; + } + + button:not(:disabled):active, + .btn:not(:disabled):active, + .tool-card:active, + .lp .lp-btn:not(:disabled):active, + .lp .lp-pill-btn:active, + .lp a.lp-cell:active, + .copy-btn:active { + transform: none; + } + + ::view-transition-old(root), + ::view-transition-new(root) { + animation: none; + } + + .solid-spinner { + animation-duration: 2s; + } +} + +/* Nav links group: close it on both sides, matching the hatch on either side */ +.lp .lp-nav-links { + border-left: 1px solid var(--lp-line); +} + +/* Phones: drop the frame gutter and side rules so content isn't cramped */ +@media (max-width: 639px) { + .lp .lp-frame { + width: 100%; + } + + .lp .lp-sec > .lp-frame, + .lp .lp-nav-inner { + border-left: 0; + border-right: 0; + } +} + +/* Kickers: no dash rules */ +.lp .lp-kicker::before, +.lp .lp-kicker::after { + display: none; +} + +/* Collapsed category: max-height:0 still leaves the 1px top+bottom rules */ +.lp #tool-grid .category-group.collapsed .category-tools, +.lp .category-group.collapsed .category-tools { + border-width: 0; +} + +/* Decorative corner + markers: removed. */ +.lp .lp-cross::after { + content: none; +} + +/* Section headings: .line is forced display:block purely so the two-tone + colour split lands on separate rows. Let them flow and wrap naturally + instead; the .dim span still gives the two-tone treatment. */ +.lp .lp-h2 .line { + display: inline; +} + +/* Engine product pages: pricing tiers */ +.lp .lp-tier { + justify-content: flex-start; + min-height: 0; + gap: 0; +} + +.lp .lp-tier h3 { + margin: 0; + font-size: 18px; +} + +.lp .lp-tier-for { + margin: 6px 0 0; + font-size: 13px; + line-height: 1.5; + color: var(--lp-dim); + min-height: 3em; +} + +.lp .lp-tier-price { + display: flex; + align-items: baseline; + gap: 6px; + margin: 18px 0 20px; + padding-top: 18px; + border-top: 1px solid var(--lp-line); +} + +.lp .lp-tier-price b { + font-family: var(--lp-display); + font-size: 34px; + font-weight: 700; + letter-spacing: -0.035em; + line-height: 1; + color: var(--lp-text); +} + +.lp .lp-tier-price span { + font-size: 14px; + white-space: nowrap; + color: var(--lp-text-2); +} + +.lp .lp-tier-from { + align-self: center; +} + +.lp .lp-tier ul { + list-style: none; + margin: 0 0 24px; + padding: 0; + display: grid; + gap: 10px; + flex: 1; +} + +.lp .lp-tier li { + display: flex; + align-items: flex-start; + gap: 10px; + font-size: 14px; + line-height: 1.5; + color: var(--lp-text-2); +} + +.lp .lp-tier li svg { + flex: none; + width: 15px; + height: 15px; + margin-top: 2px; + color: var(--lp-accent-2); +} + +.lp .lp-tier .lp-btn { + width: 100%; +} + +.lp .lp-note { + display: flex; + align-items: flex-start; + gap: 14px; + padding: 20px var(--lp-pad); + border: 1px solid var(--lp-line); + background: var(--lp-bg-2); +} + +.lp .lp-note svg { + flex: none; + width: 20px; + height: 20px; + margin-top: 1px; + color: var(--lp-accent-2); +} + +.lp .lp-note p { + margin: 0; + font-size: 15px; + line-height: 1.6; + color: var(--lp-text-2); +} + +.lp .lp-note strong { + color: var(--lp-text); +} + +/* Highlighted pricing tier: landing.css only recolours h3/b/p/span on + .lp-cell--accent, so the list, icons and button need their own values. */ +.lp .lp-cell--accent.lp-tier .lp-tier-for { + color: rgba(255, 255, 255, 0.78); +} + +.lp .lp-cell--accent.lp-tier .lp-tier-price { + border-top-color: rgba(255, 255, 255, 0.3); +} + +.lp .lp-cell--accent.lp-tier .lp-tier-price span { + color: rgba(255, 255, 255, 0.82); +} + +.lp .lp-cell--accent.lp-tier li { + color: rgba(255, 255, 255, 0.92); +} + +.lp .lp-cell--accent.lp-tier li svg { + color: #ffffff; +} + +.lp .lp-cell--accent.lp-tier .lp-btn--primary { + background: #ffffff; + border-color: #ffffff; + color: var(--lp-accent-hover); + box-shadow: none; +} + +.lp .lp-cell--accent.lp-tier .lp-btn--primary:hover { + background: rgba(255, 255, 255, 0.88); + border-color: rgba(255, 255, 255, 0.88); + transform: none; +} + +/* Tier comparison table */ +.lp .lp-compare th, +.lp .lp-compare td { + padding: 14px 18px; + text-align: left; + vertical-align: top; +} + +.lp .lp-compare thead th { + font-size: 13px; + letter-spacing: 0.02em; + white-space: nowrap; +} + +.lp .lp-compare tbody th { + font-family: var(--lp-display); + font-size: 14px; + font-weight: 600; + color: var(--lp-text); + white-space: nowrap; +} + +.lp .lp-compare tbody tr:nth-child(even) { + background: rgba(255, 255, 255, 0.02); +} + +.lp .lp-compare .lp-yes svg { + width: 16px; + height: 16px; + color: var(--lp-accent-2); +} + +.lp .lp-compare .lp-no { + color: var(--lp-dim); +} + +.lp .lp-compare .lp-no svg { + width: 16px; + height: 16px; + color: var(--lp-dim); +} + +.lp .lp-table-note { + max-width: 70ch; + margin: 20px auto 0; + font-size: 14px; + line-height: 1.6; + text-align: center; + color: var(--lp-text-2); +} + +.nav-products { + position: relative; +} + +.nav-products-trigger { + display: inline-flex; + align-items: center; + gap: 4px; + background: none; + border: none; + cursor: pointer; + font-family: inherit; +} + +.nav-products-chevron { + width: 16px; + height: 16px; + transition: transform 0.2s; +} + +.nav-products-trigger[aria-expanded='true'] .nav-products-chevron { + transform: rotate(180deg); +} + +.nav-products-menu { + position: absolute; + top: calc(100% + 14px); + left: 50%; + z-index: 40; + width: 320px; + padding: 8px; + border: 1px solid rgba(255, 255, 255, 0.12); + border-radius: 12px; + background: #2a2a2a; + box-shadow: 0 18px 44px -12px rgba(0, 0, 0, 0.7); + transform: translateX(-50%); +} + +.nav-products-menu a { + display: flex; + align-items: flex-start; + gap: 12px; + padding: 12px; + border-radius: 8px; + transition: background-color 0.2s; +} + +.nav-products-menu a:hover { + background: #383838; +} + +.nav-product-text { + display: flex; + flex-direction: column; + gap: 3px; + text-align: left; +} + +.nav-product-text b { + font-size: 14px; + font-weight: 600; + color: #fff; +} + +.nav-product-text small { + font-size: 12px; + line-height: 1.45; + color: #a3a3a0; +} + +.faq-list { + max-width: 820px; + margin: 0 auto; + border: 1px solid rgba(255, 255, 255, 0.1); +} + +.faq-d { + border-bottom: 1px solid rgba(255, 255, 255, 0.1); + background-color: #2a2a2a; +} + +.faq-d:last-child { + border-bottom: 0; +} + +.faq-d-q { + display: flex; + align-items: center; + justify-content: space-between; + gap: 20px; + padding: 22px 24px; + cursor: pointer; + font-size: 18px; + font-weight: 600; + letter-spacing: -0.01em; + line-height: 1.5; + color: #f3f3f1; + list-style: none; + transition: + color 0.2s, + background-color 0.2s; +} + +.faq-d-q > h2, +.faq-d-q > h3, +.faq-d-q > h4, +.faq-d-q > span, +.faq-d-q > p { + margin: 0; + font: inherit; + color: inherit; +} + +.faq-d-q::-webkit-details-marker { + display: none; +} + +.faq-d-q::marker { + content: ''; +} + +.faq-d-q:hover { + background-color: #383838; +} + +.faq-d[open] .faq-d-q { + color: #a5b4fc; +} + +.faq-d-icon { + flex: none; + width: 20px; + height: 20px; + color: #b4b4b0; + transition: + transform 0.3s, + color 0.2s; +} + +.faq-d[open] .faq-d-icon { + transform: rotate(45deg); + color: #818cf8; +} + +.faq-d-a { + margin: 0; + padding: 0 24px 24px; + font-size: 15px; + line-height: 1.65; + color: #b4b4b0; +} + +.faq-d-a p { + margin: 0; +} + +.faq-d-a p + p { + margin-top: 0.75rem; +} + +.mobile-products-trigger { + display: flex; + align-items: center; + justify-content: center; + gap: 6px; + width: 100%; + background: none; + border: none; + cursor: pointer; + font-family: inherit; +} + +.mobile-products-chevron { + width: 16px; + height: 16px; + transition: transform 0.2s; +} + +.mobile-products-trigger[aria-expanded='true'] .mobile-products-chevron { + transform: rotate(180deg); +} + +.mobile-products-menu { + display: flex; + flex-direction: column; + gap: 0.25rem; + padding: 0.25rem 0; +} diff --git a/src/js/config/tools.ts b/src/js/config/tools.ts index 88b67fd1..9b55ab7e 100644 --- a/src/js/config/tools.ts +++ b/src/js/config/tools.ts @@ -499,7 +499,7 @@ const baseCategories = [ subtitle: 'Convert PDF files to JSON format.', }, { - href: import.meta.env.BASE_URL + 'pdf-to-docx.html', + href: import.meta.env.BASE_URL + 'pdf-to-word.html', name: 'PDF to Word', icon: 'ph-microsoft-word-logo', subtitle: 'Convert PDF files to editable Word documents.', @@ -778,10 +778,10 @@ const baseCategories = [ name: 'Secure PDF', tools: [ { - href: import.meta.env.BASE_URL + 'encrypt-pdf.html', - name: 'Encrypt PDF', + href: import.meta.env.BASE_URL + 'protect-pdf.html', + name: 'Protect PDF', icon: 'ph-lock', - subtitle: 'Lock your PDF by adding a password.', + subtitle: 'Password protect a PDF with strong AES encryption.', }, { href: import.meta.env.BASE_URL + 'sanitize-pdf.html', @@ -790,10 +790,10 @@ const baseCategories = [ subtitle: 'Remove metadata, annotations, scripts, and more.', }, { - href: import.meta.env.BASE_URL + 'decrypt-pdf.html', - name: 'Decrypt PDF', + href: import.meta.env.BASE_URL + 'unlock-pdf.html', + name: 'Unlock PDF', icon: 'ph-lock-open', - subtitle: 'Unlock PDF by removing password protection.', + subtitle: 'Remove the password from a PDF so it opens freely.', }, { href: import.meta.env.BASE_URL + 'flatten-pdf.html', diff --git a/src/js/editcore/app.js b/src/js/editcore/app.js index a270cd21..a15f59a8 100644 --- a/src/js/editcore/app.js +++ b/src/js/editcore/app.js @@ -196,7 +196,7 @@ const engineReady = new Promise((resolve) => { } : () => {}; trace('wired'); - const demo = params.get('demo'); + const demo = import.meta.env?.DEV ? params.get('demo') : null; if (demo) { try { trace('fetching ' + demo); @@ -10260,4 +10260,6 @@ export { getZoom, getDocDescription, setOnSaved, + endEdit, + saveFile, }; diff --git a/src/js/editcore/core.js b/src/js/editcore/core.js index e6e24d37..0e238dcb 100644 --- a/src/js/editcore/core.js +++ b/src/js/editcore/core.js @@ -6,6 +6,7 @@ const devBust = ''; import { applyType3LeftoverSurgery } from './type3surgery.js'; import { probePdfBytes } from './shadingsurgery.js'; +import { needsFontEmbedding } from '../utils/freetext-script'; const RUN_SIZE = 60; const FMT_SIZE = 36; @@ -338,6 +339,7 @@ const PAGE_CACHE_MAX = 6; export class PdfEngine { static localFonts = new Map(); + static fallbackFonts = new Map(); constructor(module) { this.M = module; @@ -396,6 +398,17 @@ export class PdfEngine { if (bytes) break; } if (!bytes) bytes = normLookup(fam, styleKey, want); + if ( + !bytes && + want.some((cp) => needsFontEmbedding(String.fromCodePoint(cp))) + ) { + for (const b of PdfEngine.fallbackFonts.values()) { + if (b && b.length && sfntCovers(b, want)) { + bytes = b; + break; + } + } + } if (!bytes || !bytes.length) return 0; const buf = M._ec_buffer_alloc(bytes.length); if (!buf) return 0; diff --git a/src/js/faqModal.ts b/src/js/faqModal.ts new file mode 100644 index 00000000..4bfbfba8 --- /dev/null +++ b/src/js/faqModal.ts @@ -0,0 +1,31 @@ +document.addEventListener('DOMContentLoaded', () => { + const modal = document.getElementById('faq-modal'); + const openBtn = document.getElementById('faq-open-btn'); + const closeBtn = document.getElementById('faq-close-btn'); + if (!modal || !openBtn) return; + + if (__SIMPLE_MODE__) { + openBtn.classList.add('hidden'); + modal.remove(); + return; + } + + const setOpen = (open: boolean) => { + modal.classList.toggle('hidden', !open); + modal.setAttribute('aria-hidden', open ? 'false' : 'true'); + openBtn.setAttribute('aria-expanded', open ? 'true' : 'false'); + if (open) closeBtn?.focus(); + else openBtn.focus(); + }; + + openBtn.addEventListener('click', () => setOpen(true)); + closeBtn?.addEventListener('click', () => setOpen(false)); + modal.addEventListener('click', (event) => { + if (event.target === modal) setOpen(false); + }); + document.addEventListener('keydown', (event) => { + if (event.key === 'Escape' && !modal.classList.contains('hidden')) { + setOpen(false); + } + }); +}); diff --git a/src/js/i18n/i18n.ts b/src/js/i18n/i18n.ts index df8f0f5f..f5f1aa9a 100644 --- a/src/js/i18n/i18n.ts +++ b/src/js/i18n/i18n.ts @@ -20,6 +20,7 @@ export const supportedLanguages = [ 'id', 'it', 'pt', + 'pl', 'nl', 'da', 'sv', @@ -27,6 +28,7 @@ export const supportedLanguages = [ 'ja', 'uk', 'sk', + 'ca', 'fa', ] as const; export type SupportedLanguage = (typeof supportedLanguages)[number]; @@ -46,6 +48,7 @@ export const languageNames: Record<SupportedLanguage, string> = { id: 'Bahasa Indonesia', it: 'Italiano', pt: 'Português', + pl: 'Polski', nl: 'Nederlands', da: 'Dansk', sv: 'Svenska', @@ -53,6 +56,7 @@ export const languageNames: Record<SupportedLanguage, string> = { ja: '日本語', uk: 'Українська', sk: 'Slovenčina', + ca: 'Català', fa: 'فارسی', }; @@ -69,7 +73,7 @@ export const getLanguageFromUrl = (): SupportedLanguage => { } const langMatch = path.match( - /^\/(en|ar|fr|es|de|zh|zh-TW|vi|tr|id|it|pt|nl|be|da|ko|sv|ru|ja|uk|sk|fa)(?:\/|$)/ + /^\/(en|ar|fr|es|de|zh|zh-TW|vi|tr|id|it|pt|nl|be|da|ko|sv|ru|ja|uk|sk|ca|fa)(?:\/|$)/ ); if ( langMatch && @@ -166,7 +170,7 @@ export const changeLanguage = (lang: SupportedLanguage): void => { let pagePathWithoutLang = relativePath; const langPrefixMatch = relativePath.match( - /^\/(en|ar|fr|es|de|zh|zh-TW|vi|tr|id|it|pt|nl|be|da|ko|sv|ru|ja|uk|sk|fa)(\/.*)?$/ + /^\/(en|ar|fr|es|de|zh|zh-TW|vi|tr|id|it|pt|nl|be|da|ko|sv|ru|ja|uk|sk|ca|fa)(\/.*)?$/ ); if (langPrefixMatch) { pagePathWithoutLang = langPrefixMatch[2] || '/'; @@ -263,7 +267,7 @@ export const rewriteLinks = (): void => { } const langPrefixRegex = new RegExp( - `^(${basePath.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')})?/?(en|ar|fr|es|de|zh|zh-TW|vi|tr|id|it|pt|nl|be|da|ko|sv|ru|ja|uk|sk|fa)(/|$)` + `^(${basePath.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')})?/?(en|ar|fr|es|de|zh|zh-TW|vi|tr|id|it|pt|nl|be|da|ko|sv|ru|ja|uk|sk|ca|fa)(/|$)` ); if (langPrefixRegex.test(href)) { return; diff --git a/src/js/landing.ts b/src/js/landing.ts new file mode 100644 index 00000000..54e1e869 --- /dev/null +++ b/src/js/landing.ts @@ -0,0 +1,23 @@ +const revealTargets = document.querySelectorAll<HTMLElement>('.lp-reveal'); +const reducedMotion = window.matchMedia( + '(prefers-reduced-motion: reduce)' +).matches; + +if ( + revealTargets.length > 0 && + !reducedMotion && + 'IntersectionObserver' in window +) { + document.documentElement.classList.add('lp-js'); + const observer = new IntersectionObserver( + (entries) => { + for (const entry of entries) { + if (!entry.isIntersecting) continue; + entry.target.classList.add('is-in'); + observer.unobserve(entry.target); + } + }, + { rootMargin: '0px 0px -8% 0px', threshold: 0.1 } + ); + revealTargets.forEach((target) => observer.observe(target)); +} diff --git a/src/js/logic/compress-pdf-page.ts b/src/js/logic/compress-pdf-page.ts index 99280e8b..9b4111e9 100644 --- a/src/js/logic/compress-pdf-page.ts +++ b/src/js/logic/compress-pdf-page.ts @@ -1,181 +1,520 @@ import { showLoader, hideLoader, showAlert } from '../ui.js'; -import { - downloadFile, - readFileAsArrayBuffer, - formatBytes, - getPDFDocument, -} from '../utils/helpers.js'; -import { loadPdfWithPasswordPrompt } from '../utils/password-prompt.js'; +import { downloadFile, formatBytes } from '../utils/helpers.js'; +import { deduplicateFileName } from '../utils/deduplicate-filename.js'; import { state } from '../state.js'; -import { PDFDocument } from 'pdf-lib'; import { createIcons, icons } from 'lucide'; -import { showWasmRequiredDialog } from '../utils/wasm-provider.js'; -import { loadPyMuPDF, isPyMuPDFAvailable } from '../utils/pymupdf-loader.js'; -import * as pdfjsLib from 'pdfjs-dist'; -import type { PDFDocumentProxy } from 'pdfjs-dist'; -import '../utils/setup-pdf-worker.js'; +import { + HYPER_PRESETS, + HyperError, + normalizeHyperOptions, +} from 'hyper-compress-wasm'; +import type { + HyperCompressOptions, + HyperWasmResult, +} from 'hyper-compress-wasm'; +import type { + CompressFileLock, + CompressPasswordField, + HyperOptionField, + HyperOptionSection, +} from '@/types'; +import { + applyHyperPreset, + compressWithHyper, + hasPdfHeader, + matchHyperPreset, + resolveCompressLevel, + targetSizeToBytes, + verifyHyperPassword, +} from '../utils/compress.js'; -const CONDENSE_PRESETS = { - light: { - images: { quality: 90, dpiTarget: 150, dpiThreshold: 200 }, - scrub: { metadata: false, thumbnails: true }, - subsetFonts: true, - }, - balanced: { - images: { quality: 75, dpiTarget: 96, dpiThreshold: 150 }, - scrub: { metadata: true, thumbnails: true }, - subsetFonts: true, - }, - aggressive: { - images: { quality: 50, dpiTarget: 72, dpiThreshold: 100 }, - scrub: { metadata: true, thumbnails: true, xmlMetadata: true }, - subsetFonts: true, - }, - extreme: { - images: { quality: 30, dpiTarget: 60, dpiThreshold: 96 }, - scrub: { metadata: true, thumbnails: true, xmlMetadata: true }, - subsetFonts: true, - }, -}; +type OptionChangeHandler = ( + key: keyof HyperCompressOptions, + value: number | boolean | null +) => void; -const PHOTON_PRESETS = { - light: { scale: 2.0, quality: 0.85 }, - balanced: { scale: 1.5, quality: 0.65 }, - aggressive: { scale: 1.2, quality: 0.45 }, - extreme: { scale: 1.0, quality: 0.25 }, -}; +const LOCK_MESSAGES = { + locked: { + text: 'Password protected', + color: 'text-gray-400', + field: 'border-gray-600 focus:ring-indigo-500', + }, + wrong: { + text: 'Wrong password', + color: 'text-red-400', + field: 'border-red-500 focus:ring-red-500', + }, + unlocked: { + text: 'Unlocked', + color: 'text-green-400', + field: 'border-green-500 focus:ring-green-500', + }, +} as const; -async function performCondenseCompression( - fileBlob: Blob, - level: string, - customSettings?: { - imageQuality?: number; - dpiTarget?: number; - dpiThreshold?: number; - removeMetadata?: boolean; - subsetFonts?: boolean; - convertToGrayscale?: boolean; - removeThumbnails?: boolean; +const PASSWORD_INPUT_CLASS = + 'w-full bg-gray-800 border text-gray-200 placeholder:text-gray-500 rounded-lg pl-9 pr-10 py-2 text-sm focus:outline-none focus:ring-2 focus:border-transparent'; + +const DPI_CHOICES = [ + { value: 0, label: 'None' }, + { value: 72, label: '72 DPI' }, + { value: 96, label: '96 DPI' }, + { value: 144, label: '144 DPI' }, + { value: 150, label: '150 DPI' }, + { value: 200, label: '200 DPI' }, + { value: 300, label: '300 DPI' }, +]; + +const HYPER_OPTION_SECTIONS: HyperOptionSection[] = [ + { + title: 'Images', + open: true, + fields: [ + { + key: 'imageQuality', + type: 'number', + label: 'Image quality', + min: 20, + max: 100, + help: 'JPEG quality for re-encoded images, 20 to 100. Lower means smaller files and more visible artifacts.', + }, + { + key: 'maxDpi', + type: 'dpi', + label: 'Maximum resolution', + help: 'Images above this are resampled down. None leaves resolution alone.', + }, + { + key: 'forceDownsample', + type: 'bool', + label: 'Always resample to that resolution', + help: 'Resamples even when an image is only slightly above the limit.', + }, + { + key: 'grayscale', + type: 'bool', + label: 'Convert to grayscale', + help: 'Reduces file size by removing color information.', + }, + { + key: 'reduceColor', + type: 'bool', + label: 'Reduce color complexity', + help: 'Only where it does not change how the image looks.', + }, + { + key: 'clipImages', + type: 'bool', + label: 'Clip invisible parts of images', + }, + { + key: 'removeAlternates', + type: 'bool', + label: 'Remove alternate images', + }, + { + key: 'flattenIcc', + type: 'bool', + label: 'Simplify large color profiles', + }, + { + key: 'preferJpx', + type: 'bool', + label: 'Prefer JPEG 2000 for photos', + help: 'Tries JPEG 2000 against JPEG for each image and keeps the smaller one.', + }, + { + key: 'lossless', + type: 'bool', + label: 'Lossless (no image re-encoding)', + help: 'Turns off quality, resolution and every other lossy image step.', + }, + ], + }, + { + title: 'Fonts', + fields: [ + { + key: 'subsetFonts', + type: 'bool', + label: 'Subset embedded fonts', + help: 'Keeps only the characters the document uses.', + }, + { key: 'mergeFonts', type: 'bool', label: 'Merge duplicate fonts' }, + { + key: 'removeStandardFonts', + type: 'bool', + label: 'Unembed standard fonts', + help: 'PDF readers supply the 14 standard PDF fonts themselves.', + }, + { + key: 'unembedAliasedFonts', + type: 'bool', + label: 'Unembed standard-font look-alikes', + help: 'Arial, Times New Roman and Courier New map to the built-in equivalents. Only applied when safe.', + }, + ], + }, + { + title: 'Content', + fields: [ + { + key: 'removeAnnots', + type: 'bool', + label: 'Flatten annotations', + help: 'Comments and markup become part of the page. Form fields and links keep working.', + }, + { + key: 'flattenForms', + type: 'bool', + label: 'Flatten form fields', + help: 'Form fields stop being fillable.', + }, + { + key: 'flattenLinks', + type: 'bool', + label: 'Flatten links', + help: 'Links stop being clickable.', + }, + ], + }, + { + title: 'Cleanup', + fields: [ + { + key: 'removeThumbnails', + type: 'bool', + label: 'Remove page thumbnails', + }, + { + key: 'removeAppData', + type: 'bool', + label: 'Remove application data', + help: 'Private data that editing apps store inside the PDF.', + }, + { key: 'removeThreads', type: 'bool', label: 'Remove article threads' }, + { + key: 'removeSpiderInfo', + type: 'bool', + label: 'Remove web capture information', + }, + { + key: 'removeStructTree', + type: 'bool', + label: 'Remove document structure tree', + help: 'Drops tagging, which screen readers rely on.', + }, + { + key: 'removeOutputIntents', + type: 'bool', + label: 'Remove output intents', + }, + ], + }, + { + title: 'Rasterize', + fields: [ + { + key: 'rasterizePages', + type: 'bool', + label: 'Rasterize pages to images', + help: 'For image-only PDFs such as scans. Skipped when the document has selectable text, unless Override content protection is on.', + }, + { + key: 'rasterizeDpi', + type: 'number', + label: 'Rasterize resolution (DPI)', + min: 36, + max: 600, + }, + { + key: 'rasterizeQuality', + type: 'number', + label: 'Rasterize quality', + min: 1, + max: 100, + }, + ], + }, + { + title: 'Output', + fields: [ + { + key: 'preserveConformance', + type: 'bool', + label: 'Preserve PDF/A', + help: 'Keeps a PDF/A file conformant. Brotli and rasterizing are skipped while this is on.', + }, + { + key: 'brotli', + type: 'bool', + label: 'Brotli compression (PDF 2.0)', + help: 'Smaller files, but only readers with Brotli support can open them, such as Firefox, MuPDF 1.26+ and Ghostscript 10.06+.', + }, + ], + }, +]; + +function buildOptionField( + field: HyperOptionField, + onChange: OptionChangeHandler +): { element: HTMLElement; sync: (options: HyperCompressOptions) => void } { + const id = `hyper-opt-${field.key}`; + const wrapper = document.createElement('div'); + let help: HTMLParagraphElement | null = null; + if (field.help) { + help = document.createElement('p'); + help.className = 'text-xs text-gray-500 mt-0.5'; + help.textContent = field.help; } -) { - // Load PyMuPDF dynamically from user-provided URL - const pymupdf = await loadPyMuPDF(); - const preset = - CONDENSE_PRESETS[level as keyof typeof CONDENSE_PRESETS] || - CONDENSE_PRESETS.balanced; + if (field.type === 'bool') { + const label = document.createElement('label'); + label.className = 'flex items-start gap-2 cursor-pointer'; + const input = document.createElement('input'); + input.type = 'checkbox'; + input.id = id; + input.className = 'w-4 h-4 mt-0.5 rounded flex-shrink-0'; + input.addEventListener('change', () => onChange(field.key, input.checked)); + const text = document.createElement('span'); + const title = document.createElement('span'); + title.className = 'block text-sm text-gray-300'; + title.textContent = field.label; + text.append(title); + if (help) text.append(help); + label.append(input, text); + wrapper.append(label); + return { + element: wrapper, + sync: (options) => { + input.checked = Boolean(options[field.key]); + }, + }; + } - const dpiTarget = customSettings?.dpiTarget ?? preset.images.dpiTarget; - const userThreshold = - customSettings?.dpiThreshold ?? preset.images.dpiThreshold; - const dpiThreshold = Math.max(userThreshold, dpiTarget + 10); + const label = document.createElement('label'); + label.htmlFor = id; + label.className = 'block mb-1 text-xs font-medium text-gray-400'; + label.textContent = field.label; + wrapper.append(label); + const controlClass = + 'w-full bg-gray-700 border border-gray-600 text-white rounded-lg p-2 text-sm'; - const options = { - images: { - enabled: true, - quality: customSettings?.imageQuality ?? preset.images.quality, - dpiTarget, - dpiThreshold, - convertToGray: customSettings?.convertToGrayscale ?? false, - }, - scrub: { - metadata: customSettings?.removeMetadata ?? preset.scrub.metadata, - thumbnails: customSettings?.removeThumbnails ?? preset.scrub.thumbnails, - xmlMetadata: - 'xmlMetadata' in preset.scrub - ? (preset.scrub as { xmlMetadata: boolean }).xmlMetadata - : false, - }, - subsetFonts: customSettings?.subsetFonts ?? preset.subsetFonts, - save: { - garbage: 4 as const, - deflate: true, - clean: true, - useObjstms: true, + if (field.type === 'dpi') { + const select = document.createElement('select'); + select.id = id; + select.className = controlClass; + for (const choice of DPI_CHOICES) { + const option = document.createElement('option'); + option.value = String(choice.value); + option.textContent = choice.label; + select.append(option); + } + select.addEventListener('change', () => + onChange(field.key, Number(select.value)) + ); + wrapper.append(select); + if (help) wrapper.append(help); + return { + element: wrapper, + sync: (options) => { + select.value = String(options[field.key]); + }, + }; + } + + const input = document.createElement('input'); + input.type = 'number'; + input.id = id; + input.min = String(field.min); + input.max = String(field.max); + input.step = '1'; + input.className = controlClass; + input.addEventListener('change', () => { + const value = Number(input.value); + if (input.value.trim() === '' || !Number.isFinite(value)) { + onChange(field.key, null); + return; + } + onChange( + field.key, + Math.min(field.max, Math.max(field.min, Math.round(value))) + ); + }); + wrapper.append(input); + if (help) wrapper.append(help); + return { + element: wrapper, + sync: (options) => { + input.value = String(options[field.key]); }, }; - - try { - const result = await pymupdf.compressPdf(fileBlob, options); - return result; - } catch (error: unknown) { - const errorMessage = error instanceof Error ? error.message : String(error); - if ( - errorMessage.includes('PatternType') || - errorMessage.includes('pattern') - ) { - console.warn( - '[CompressPDF] Pattern error detected, retrying without image rewriting:', - errorMessage - ); - - const fallbackOptions = { - ...options, - images: { - ...options.images, - enabled: false, - }, - }; - - const result = await pymupdf.compressPdf(fileBlob, fallbackOptions); - return { ...result, usedFallback: true }; - } - - throw new Error(`PDF compression failed: ${errorMessage}`, { - cause: error, - }); - } } -async function performPhotonCompression( - arrayBuffer: ArrayBuffer, - level: string, - file?: File -) { - let pdfJsDoc: PDFDocumentProxy; - if (file) { - hideLoader(); - const result = await loadPdfWithPasswordPrompt(file); - if (!result) return null; - showLoader('Running Photon compression...'); - pdfJsDoc = result.pdf; - } else { - pdfJsDoc = await getPDFDocument({ data: arrayBuffer }).promise; +function renderHyperOptions( + root: HTMLElement, + onChange: OptionChangeHandler +): (options: HyperCompressOptions) => void { + const syncs: ((options: HyperCompressOptions) => void)[] = []; + for (const section of HYPER_OPTION_SECTIONS) { + const details = document.createElement('details'); + details.className = 'group rounded-lg border border-gray-700'; + details.open = Boolean(section.open); + const summary = document.createElement('summary'); + summary.className = + 'flex items-center justify-between cursor-pointer select-none list-none px-3 py-2 text-sm font-medium text-gray-200 [&::-webkit-details-marker]:hidden'; + summary.textContent = section.title; + const chevron = document.createElement('i'); + chevron.setAttribute('data-lucide', 'chevron-down'); + chevron.className = + 'w-4 h-4 text-gray-400 transition-transform group-open:rotate-180'; + summary.append(chevron); + const body = document.createElement('div'); + body.className = 'space-y-3 px-3 pb-3'; + for (const field of section.fields) { + const { element, sync } = buildOptionField(field, onChange); + body.append(element); + syncs.push(sync); + } + details.append(summary, body); + root.append(details); } - const newPdfDoc = await PDFDocument.create(); - const settings = - PHOTON_PRESETS[level as keyof typeof PHOTON_PRESETS] || - PHOTON_PRESETS.balanced; + return (options) => { + for (const sync of syncs) sync(options); + }; +} - for (let i = 1; i <= pdfJsDoc.numPages; i++) { - const page = await pdfJsDoc.getPage(i); - const viewport = page.getViewport({ scale: settings.scale }); - const canvas = document.createElement('canvas'); - const context = canvas.getContext('2d'); - canvas.height = viewport.height; - canvas.width = viewport.width; - - await page.render({ canvasContext: context, viewport, canvas: canvas }) - .promise; - - const jpegBlob = await new Promise<Blob>((resolve) => - canvas.toBlob( - (blob) => resolve(blob as Blob), - 'image/jpeg', - settings.quality - ) +function describeCompressResult( + result: HyperWasmResult, + options: HyperCompressOptions, + targetBytes: number | undefined +): { title: string; message: string; type: string } { + const saved = result.originalSize - result.compressedSize; + const parts: string[] = []; + if (saved > 0) { + const percent = ((saved / result.originalSize) * 100).toFixed(1); + parts.push( + `File size reduced from ${formatBytes(result.originalSize)} to ${formatBytes(result.compressedSize)} (Saved ${percent}%).` + ); + } else { + parts.push( + `Could not reduce file size further. Original: ${formatBytes(result.originalSize)}, New: ${formatBytes(result.compressedSize)}.` ); - const jpegBytes = await jpegBlob.arrayBuffer(); - const jpegImage = await newPdfDoc.embedJpg(jpegBytes); - const newPage = newPdfDoc.addPage([viewport.width, viewport.height]); - newPage.drawImage(jpegImage, { - x: 0, - y: 0, - width: viewport.width, - height: viewport.height, - }); } - return await newPdfDoc.save(); + + const preservingPdfa = options.preserveConformance && result.pdfa !== null; + if (targetBytes !== undefined) { + if (result.metTarget) { + parts.push(`It fits the ${formatBytes(targetBytes)} target.`); + } else if (preservingPdfa) { + parts.push( + `It is still above the ${formatBytes(targetBytes)} target because Preserve PDF/A limits how far it can be compressed.` + ); + } else { + parts.push( + `It is still above the ${formatBytes(targetBytes)} target. This is the smallest these settings allow; for image-only scans, turn on Rasterize pages under Custom Settings.` + ); + } + } + + if (result.signaturesRemoved) { + parts.push( + 'Its digital signatures were removed so it could be compressed. Sign it again if needed.' + ); + } + + if (result.pdfaOutcome === 'claim-withdrawn') { + parts.push( + 'It no longer claims PDF/A conformance. Turn on Preserve PDF/A under Custom Settings to keep it.' + ); + } else if (preservingPdfa && (options.brotli || options.rasterizePages)) { + parts.push('Brotli and rasterizing were skipped to keep it PDF/A.'); + } + + if (saved <= 0) { + return { + title: 'Compression Finished', + message: parts.join(' '), + type: 'warning', + }; + } + if (result.metTarget === false) { + return { + title: 'Target Not Reached', + message: parts.join(' '), + type: 'warning', + }; + } + return { + title: 'Compression Complete', + message: parts.join(' '), + type: 'success', + }; +} + +function describeCompressionError(err: unknown, hadPassword: boolean): string { + if (err instanceof HyperError && err.code === 'decrypt_failed') { + return hadPassword ? 'wrong password' : 'password protected'; + } + return err instanceof Error ? err.message : String(err); +} + +function createPasswordField( + label: string, + value: string, + onInput: (value: string) => void +): CompressPasswordField { + const root = document.createElement('div'); + root.className = 'relative w-full sm:max-w-sm'; + + const lockIcon = createIcon( + 'lock', + 'pointer-events-none absolute left-3 top-1/2 -translate-y-1/2 text-gray-400' + ); + const unlockIcon = createIcon( + 'lock-open', + 'pointer-events-none absolute left-3 top-1/2 -translate-y-1/2 text-green-400 hidden' + ); + + const input = document.createElement('input'); + input.type = 'password'; + input.placeholder = 'Enter password'; + input.autocomplete = 'off'; + input.value = value; + input.setAttribute('aria-label', label); + input.className = `${PASSWORD_INPUT_CLASS} ${LOCK_MESSAGES.locked.field}`; + input.addEventListener('input', () => onInput(input.value)); + + const showIcon = createIcon('eye', ''); + const hideIcon = createIcon('eye-slash', 'hidden'); + const toggle = document.createElement('button'); + toggle.type = 'button'; + toggle.className = + 'absolute inset-y-0 right-0 flex items-center px-3 text-gray-400 hover:text-gray-200'; + toggle.setAttribute('aria-label', 'Show password'); + toggle.append(showIcon, hideIcon); + toggle.addEventListener('click', () => { + const reveal = input.type === 'password'; + input.type = reveal ? 'text' : 'password'; + toggle.setAttribute( + 'aria-label', + reveal ? 'Hide password' : 'Show password' + ); + showIcon.classList.toggle('hidden', reveal); + hideIcon.classList.toggle('hidden', !reveal); + }); + + root.append(lockIcon, unlockIcon, input, toggle); + + const status = document.createElement('p'); + status.className = 'text-xs mt-1 text-gray-400'; + status.setAttribute('aria-live', 'polite'); + return { root, input, status, lockIcon, unlockIcon }; +} + +function createIcon(name: string, className: string): HTMLElement { + const icon = document.createElement('i'); + icon.className = `ph ph-${name} text-base ${className}`; + return icon; } document.addEventListener('DOMContentLoaded', () => { @@ -186,11 +525,18 @@ document.addEventListener('DOMContentLoaded', () => { const clearFilesBtn = document.getElementById('clear-files-btn'); const processBtn = document.getElementById('process-btn'); const backBtn = document.getElementById('back-to-tools'); - const algorithmSelect = document.getElementById( - 'compression-algorithm' + const levelSelect = document.getElementById( + 'compression-level' ) as HTMLSelectElement; - const condenseInfo = document.getElementById('condense-info'); - const photonInfo = document.getElementById('photon-info'); + const targetSizeInput = document.getElementById( + 'target-size' + ) as HTMLInputElement; + const targetSizeUnit = document.getElementById( + 'target-size-unit' + ) as HTMLSelectElement; + const overrideInput = document.getElementById( + 'override-protection' + ) as HTMLInputElement; const toggleCustomSettings = document.getElementById( 'toggle-custom-settings' ); @@ -198,8 +544,13 @@ document.addEventListener('DOMContentLoaded', () => { const customSettingsChevron = document.getElementById( 'custom-settings-chevron' ); + const hyperOptionsRoot = document.getElementById('hyper-options'); - let useCustomSettings = false; + const defaultTargetUnit = + targetSizeUnit?.dataset.defaultUnit === 'kb' ? 'kb' : 'mb'; + let options: HyperCompressOptions = normalizeHyperOptions( + HYPER_PRESETS.medium + ); if (backBtn) { backBtn.addEventListener('click', () => { @@ -207,33 +558,164 @@ document.addEventListener('DOMContentLoaded', () => { }); } - // Toggle algorithm info - if (algorithmSelect && condenseInfo && photonInfo) { - algorithmSelect.addEventListener('change', () => { - if (algorithmSelect.value === 'condense') { - condenseInfo.classList.remove('hidden'); - photonInfo.classList.add('hidden'); - } else { - condenseInfo.classList.add('hidden'); - photonInfo.classList.remove('hidden'); - } - }); - } + const setCustomSettingsOpen = (open: boolean) => { + if (!customSettingsPanel || !customSettingsChevron) return; + customSettingsPanel.classList.toggle('hidden', !open); + customSettingsChevron.style.transform = open + ? 'rotate(180deg)' + : 'rotate(0deg)'; + }; + + const syncLevel = () => { + if (levelSelect) levelSelect.value = matchHyperPreset(options) ?? 'custom'; + }; + + const syncOptionControls: (options: HyperCompressOptions) => void = + hyperOptionsRoot + ? renderHyperOptions(hyperOptionsRoot, (key, value) => { + if (value !== null) { + options = normalizeHyperOptions({ ...options, [key]: value }); + } + syncOptionControls(options); + syncLevel(); + }) + : () => {}; + + levelSelect?.addEventListener('change', () => { + if (levelSelect.value === 'custom') { + setCustomSettingsOpen(true); + return; + } + options = applyHyperPreset( + resolveCompressLevel(levelSelect.value), + options + ); + syncOptionControls(options); + }); + + overrideInput?.addEventListener('change', () => { + options = { + ...options, + rasterizeText: overrideInput.checked, + removeSignatures: overrideInput.checked, + }; + }); // Toggle custom settings panel - if (toggleCustomSettings && customSettingsPanel && customSettingsChevron) { - toggleCustomSettings.addEventListener('click', () => { - customSettingsPanel.classList.toggle('hidden'); - customSettingsChevron.style.transform = - customSettingsPanel.classList.contains('hidden') - ? 'rotate(0deg)' - : 'rotate(180deg)'; - // Mark that user wants to use custom settings - if (!customSettingsPanel.classList.contains('hidden')) { - useCustomSettings = true; + toggleCustomSettings?.addEventListener('click', () => { + setCustomSettingsOpen( + customSettingsPanel?.classList.contains('hidden') ?? false + ); + }); + + const applyDefaults = () => { + options = normalizeHyperOptions(HYPER_PRESETS.medium); + if (overrideInput) overrideInput.checked = false; + if (targetSizeInput) targetSizeInput.value = targetSizeInput.defaultValue; + if (targetSizeUnit) targetSizeUnit.value = defaultTargetUnit; + setCustomSettingsOpen(false); + syncOptionControls(options); + syncLevel(); + }; + + const fileLocks = new Map<File, CompressFileLock>(); + const lockViews = new Map<File, CompressPasswordField>(); + let sharedView: CompressPasswordField | null = null; + let sharedPassword = ''; + let sharedAttempt = 0; + let sharedMissed = false; + + const isProtected = ( + lock: CompressFileLock | undefined + ): lock is CompressFileLock => + lock !== undefined && lock.status !== 'checking' && lock.status !== 'open'; + + const protectedFiles = () => + state.files.filter((file) => isProtected(fileLocks.get(file))); + + const tryPassword = async ( + file: File, + password: string + ): Promise<boolean | null> => { + try { + return await verifyHyperPassword(await file.arrayBuffer(), password); + } catch (err) { + console.error('[CompressPDF] Password check failed:', err); + return null; + } + }; + + const paintShared = () => { + if (!sharedView) return; + const files = protectedFiles(); + const unlocked = files.filter( + (file) => fileLocks.get(file)?.status === 'unlocked' + ).length; + if (sharedMissed && unlocked < files.length) { + sharedView.status.textContent = + 'That password did not open any of the remaining files.'; + sharedView.status.className = 'text-xs mt-1 text-red-400'; + return; + } + sharedView.status.textContent = `Unlocked ${unlocked} of ${files.length} files.`; + sharedView.status.className = `text-xs mt-1 ${unlocked === files.length ? 'text-green-400' : 'text-gray-400'}`; + }; + + const paintLock = (file: File) => { + const lock = fileLocks.get(file); + const view = lockViews.get(file); + if (lock && view && lock.status !== 'checking' && lock.status !== 'open') { + const { text, color, field } = LOCK_MESSAGES[lock.status]; + if (view.input.value !== lock.password) view.input.value = lock.password; + view.status.textContent = text; + view.status.className = `truncate ${color}`; + view.input.className = `${PASSWORD_INPUT_CLASS} ${field}`; + view.lockIcon.classList.toggle('hidden', lock.status === 'unlocked'); + view.unlockIcon.classList.toggle('hidden', lock.status !== 'unlocked'); + } + paintShared(); + }; + + const checkCardPassword = (file: File, lock: CompressFileLock) => { + const attempt = ++lock.attempt; + lock.status = 'locked'; + paintLock(file); + if (!lock.password) return; + window.setTimeout(async () => { + if (lock.attempt !== attempt) return; + const valid = await tryPassword(file, lock.password); + if (lock.attempt !== attempt || valid === null) return; + lock.status = valid ? 'unlocked' : 'wrong'; + paintLock(file); + }, 400); + }; + + const applySharedPassword = (password: string) => { + const attempt = ++sharedAttempt; + sharedPassword = password; + sharedMissed = false; + paintShared(); + if (!password) return; + window.setTimeout(async () => { + if (attempt !== sharedAttempt) return; + let matched = 0; + for (const file of protectedFiles()) { + const lock = fileLocks.get(file); + if (!lock || lock.status === 'unlocked') continue; + const before = lock.attempt; + const valid = await tryPassword(file, password); + if (attempt !== sharedAttempt) return; + if (!valid || lock.attempt !== before) continue; + lock.attempt++; + lock.password = password; + lock.status = 'unlocked'; + matched++; + paintLock(file); } - }); - } + sharedMissed = matched === 0; + paintShared(); + }, 400); + }; const updateUI = async () => { if (!compressOptions) return; @@ -241,16 +723,41 @@ document.addEventListener('DOMContentLoaded', () => { if (state.files.length > 0) { const fileDisplayArea = document.getElementById('file-display-area'); if (fileDisplayArea) { - fileDisplayArea.innerHTML = ''; + const focusedFile = [...lockViews].find( + ([, view]) => view.input === document.activeElement + )?.[0]; + const sharedFocused = sharedView?.input === document.activeElement; + lockViews.clear(); + sharedView = null; + fileDisplayArea.replaceChildren(); - for (let index = 0; index < state.files.length; index++) { - const file = state.files[index]; + if (protectedFiles().length > 1) { + const sharedDiv = document.createElement('div'); + sharedDiv.className = 'bg-gray-700 p-3 rounded-lg text-sm'; + const sharedLabel = document.createElement('div'); + sharedLabel.className = 'font-medium text-gray-200 text-sm'; + sharedLabel.textContent = 'Password for all locked files'; + sharedView = createPasswordField( + 'Password for all locked files', + sharedPassword, + applySharedPassword + ); + sharedView.root.classList.add('mt-2'); + sharedDiv.append(sharedLabel, sharedView.root, sharedView.status); + fileDisplayArea.appendChild(sharedDiv); + } + + for (const file of state.files) { + const lock = fileLocks.get(file); + const locked = isProtected(lock); const fileDiv = document.createElement('div'); - fileDiv.className = - 'flex items-center justify-between bg-gray-700 p-3 rounded-lg text-sm'; + fileDiv.className = 'bg-gray-700 p-3 rounded-lg text-sm'; + + const headerRow = document.createElement('div'); + headerRow.className = 'flex items-center justify-between'; const infoContainer = document.createElement('div'); - infoContainer.className = 'flex flex-col overflow-hidden'; + infoContainer.className = 'flex flex-col flex-1 min-w-0'; const nameSpan = document.createElement('div'); nameSpan.className = @@ -262,292 +769,275 @@ document.addEventListener('DOMContentLoaded', () => { metaSpan.textContent = formatBytes(file.size); infoContainer.append(nameSpan, metaSpan); + headerRow.append(infoContainer); + fileDiv.append(headerRow); + + if (locked) { + const view = createPasswordField( + `Password for ${file.name}`, + lock.password, + (value) => { + lock.password = value; + checkCardPassword(file, lock); + } + ); + lockViews.set(file, view); + view.root.classList.add('mt-3'); + const sizeSpan = document.createElement('span'); + sizeSpan.className = 'flex-shrink-0 whitespace-nowrap'; + sizeSpan.textContent = `${formatBytes(file.size)} ·`; + metaSpan.className = 'flex gap-1 min-w-0 text-xs text-gray-400'; + metaSpan.replaceChildren(sizeSpan, view.status); + fileDiv.append(view.root); + } const removeBtn = document.createElement('button'); + removeBtn.type = 'button'; removeBtn.className = 'ml-4 text-red-400 hover:text-red-300 flex-shrink-0'; - removeBtn.innerHTML = '<i data-lucide="trash-2" class="w-4 h-4"></i>'; + removeBtn.setAttribute('aria-label', `Remove ${file.name}`); + removeBtn.append(createIcon('trash', '')); removeBtn.onclick = () => { - state.files = state.files.filter((_, i) => i !== index); + state.files = state.files.filter((f) => f !== file); + fileLocks.delete(file); updateUI(); }; - fileDiv.append(infoContainer, removeBtn); + headerRow.append(removeBtn); fileDisplayArea.appendChild(fileDiv); } - createIcons({ icons }); + for (const file of lockViews.keys()) paintLock(file); + paintShared(); + if (focusedFile) lockViews.get(focusedFile)?.input.focus(); + if (sharedFocused) sharedView?.input.focus(); } compressOptions.classList.remove('hidden'); } else { compressOptions.classList.add('hidden'); // Clear file display area const fileDisplayArea = document.getElementById('file-display-area'); - if (fileDisplayArea) fileDisplayArea.innerHTML = ''; + if (fileDisplayArea) fileDisplayArea.replaceChildren(); } }; const resetState = () => { state.files = []; state.pdfDoc = null; - - const compressionLevel = document.getElementById( - 'compression-level' - ) as HTMLSelectElement; - if (compressionLevel) compressionLevel.value = 'balanced'; - - if (algorithmSelect) algorithmSelect.value = 'condense'; - - useCustomSettings = false; - if (customSettingsPanel) customSettingsPanel.classList.add('hidden'); - if (customSettingsChevron) - customSettingsChevron.style.transform = 'rotate(0deg)'; - - const imageQuality = document.getElementById( - 'image-quality' - ) as HTMLInputElement; - const dpiTarget = document.getElementById('dpi-target') as HTMLInputElement; - const dpiThreshold = document.getElementById( - 'dpi-threshold' - ) as HTMLInputElement; - const removeMetadata = document.getElementById( - 'remove-metadata' - ) as HTMLInputElement; - const subsetFonts = document.getElementById( - 'subset-fonts' - ) as HTMLInputElement; - const convertToGrayscale = document.getElementById( - 'convert-to-grayscale' - ) as HTMLInputElement; - const removeThumbnails = document.getElementById( - 'remove-thumbnails' - ) as HTMLInputElement; - - if (imageQuality) imageQuality.value = '75'; - if (dpiTarget) dpiTarget.value = '96'; - if (dpiThreshold) dpiThreshold.value = '150'; - if (removeMetadata) removeMetadata.checked = true; - if (subsetFonts) subsetFonts.checked = true; - if (convertToGrayscale) convertToGrayscale.checked = false; - if (removeThumbnails) removeThumbnails.checked = true; - - if (condenseInfo) condenseInfo.classList.remove('hidden'); - if (photonInfo) photonInfo.classList.add('hidden'); - + fileLocks.clear(); + sharedPassword = ''; + sharedAttempt++; + sharedMissed = false; + applyDefaults(); updateUI(); }; - const compress = async () => { - const level = ( - document.getElementById('compression-level') as HTMLSelectElement - ).value; - const algorithm = ( - document.getElementById('compression-algorithm') as HTMLSelectElement - ).value; - const convertToGrayscale = - (document.getElementById('convert-to-grayscale') as HTMLInputElement) - ?.checked ?? false; + const compressSingle = async ( + file: File, + runOptions: HyperCompressOptions, + targetBytes: number | undefined + ) => { + const lock = fileLocks.get(file); + const password = isProtected(lock) ? lock.password : ''; + showLoader( + targetBytes === undefined + ? 'Compressing...' + : `Compressing to ${formatBytes(targetBytes)}...` + ); + const result = await compressWithHyper(await file.arrayBuffer(), { + options: runOptions, + targetSizeBytes: targetBytes, + password, + }); + hideLoader(); - let customSettings: - | { - imageQuality?: number; - dpiTarget?: number; - dpiThreshold?: number; - removeMetadata?: boolean; - subsetFonts?: boolean; - convertToGrayscale?: boolean; - removeThumbnails?: boolean; - } - | undefined; - - if (useCustomSettings) { - const imageQuality = - parseInt( - (document.getElementById('image-quality') as HTMLInputElement)?.value - ) || 75; - const dpiTarget = - parseInt( - (document.getElementById('dpi-target') as HTMLInputElement)?.value - ) || 96; - const dpiThreshold = - parseInt( - (document.getElementById('dpi-threshold') as HTMLInputElement)?.value - ) || 150; - const removeMetadata = - (document.getElementById('remove-metadata') as HTMLInputElement) - ?.checked ?? true; - const subsetFonts = - (document.getElementById('subset-fonts') as HTMLInputElement) - ?.checked ?? true; - const removeThumbnails = - (document.getElementById('remove-thumbnails') as HTMLInputElement) - ?.checked ?? true; - - customSettings = { - imageQuality, - dpiTarget, - dpiThreshold, - removeMetadata, - subsetFonts, - convertToGrayscale, - removeThumbnails, - }; - } else { - customSettings = convertToGrayscale ? { convertToGrayscale } : undefined; + if (result.signed) { + showAlert( + 'Signed PDF Not Changed', + `${file.name} is digitally signed, so it was left as it is. Compressing it would break the signature. To compress it anyway, turn on Override content protection under Custom Settings; its signatures will be removed.`, + 'warning', + () => resetState() + ); + return; } + downloadFile( + new Blob([new Uint8Array(result.data)], { type: 'application/pdf' }), + file.name + ); + const { title, message, type } = describeCompressResult( + result, + runOptions, + targetBytes + ); + showAlert( + title, + password + ? `${message} It was password protected, so the compressed copy has no password. Use Protect PDF to add one again.` + : message, + type, + () => resetState() + ); + }; + + const compressBatch = async ( + files: File[], + runOptions: HyperCompressOptions, + targetBytes: number | undefined + ) => { + showLoader('Compressing multiple PDFs...'); + const JSZip = (await import('jszip')).default; + const zip = new JSZip(); + let totalOriginalSize = 0; + let totalCompressedSize = 0; + let compressedCount = 0; + let signedCount = 0; + let signaturesRemovedCount = 0; + let missedTargetCount = 0; + let withdrawnPdfaCount = 0; + let unlockedCount = 0; + const failures: string[] = []; + const zipNames = new Set<string>(); + + for (let i = 0; i < files.length; i++) { + const file = files[i]; + showLoader(`Compressing ${i + 1}/${files.length}: ${file.name}...`); + const lock = fileLocks.get(file); + const password = isProtected(lock) ? lock.password : ''; + try { + const result = await compressWithHyper(await file.arrayBuffer(), { + options: runOptions, + targetSizeBytes: targetBytes, + password, + }); + if (password) unlockedCount++; + if (result.signed) signedCount++; + if (result.signaturesRemoved) signaturesRemovedCount++; + if (result.metTarget === false) missedTargetCount++; + if (result.pdfaOutcome === 'claim-withdrawn') withdrawnPdfaCount++; + zip.file(deduplicateFileName(file.name, zipNames), result.data); + totalOriginalSize += file.size; + totalCompressedSize += result.data.length; + compressedCount++; + } catch (err) { + console.error('[CompressPDF] Error:', err); + failures.push( + `${file.name} (${describeCompressionError(err, password !== '')})` + ); + } + } + + if (compressedCount === 0) { + hideLoader(); + showAlert( + 'Error', + `None of the files could be compressed: ${failures.join(', ')}.` + ); + return; + } + + showLoader('Creating ZIP...'); + const zipBlob = await zip.generateAsync({ type: 'blob' }); + downloadFile(zipBlob, 'compressed-pdfs.zip'); + hideLoader(); + + const totalSavings = totalOriginalSize - totalCompressedSize; + const parts = [ + totalSavings > 0 + ? `Compressed ${compressedCount} PDF(s). Total size reduced from ${formatBytes(totalOriginalSize)} to ${formatBytes(totalCompressedSize)} (Saved ${((totalSavings / totalOriginalSize) * 100).toFixed(1)}%).` + : `Compressed ${compressedCount} PDF(s). Total size: ${formatBytes(totalCompressedSize)}.`, + ]; + if (signedCount > 0) { + parts.push( + `${signedCount} digitally signed PDF(s) were left as they are to keep their signatures valid. Turn on Override content protection under Custom Settings to compress them anyway.` + ); + } + if (signaturesRemovedCount > 0) { + parts.push( + `${signaturesRemovedCount} had their digital signatures removed so they could be compressed.` + ); + } + if (unlockedCount > 0) { + parts.push( + `${unlockedCount} password-protected PDF(s) were unlocked, so their compressed copies have no password. Use Protect PDF to add one again.` + ); + } + if (missedTargetCount > 0 && targetBytes !== undefined) { + parts.push( + `${missedTargetCount} could not get under the ${formatBytes(targetBytes)} target.` + ); + } + if (withdrawnPdfaCount > 0) { + parts.push( + `${withdrawnPdfaCount} no longer claim PDF/A conformance. Turn on Preserve PDF/A under Custom Settings to keep it.` + ); + } + if (failures.length > 0) { + parts.push(`Skipped: ${failures.join(', ')}.`); + } + + const hasProblems = failures.length > 0 || missedTargetCount > 0; + showAlert( + hasProblems ? 'Compression Finished' : 'Compression Complete', + parts.join(' '), + hasProblems ? 'warning' : totalSavings > 0 ? 'success' : 'info', + () => resetState() + ); + }; + + const compress = async () => { + if (state.files.length === 0) { + showAlert('No Files', 'Please select at least one PDF file.'); + return; + } + + const needPassword = state.files.filter((file) => { + const lock = fileLocks.get(file); + return isProtected(lock) && (lock.status === 'wrong' || !lock.password); + }); + if (needPassword.length > 0) { + showAlert( + 'Password Needed', + needPassword.length === 1 + ? `${needPassword[0].name} is password protected. Enter its password on the file card, then compress again.` + : `These files are password protected: ${needPassword.map((file) => file.name).join(', ')}. Enter their passwords on the file cards, or remove them, then compress again.`, + 'warning', + () => lockViews.get(needPassword[0])?.input.focus() + ); + return; + } + + const runOptions = { ...options }; + const targetBytes = + targetSizeInput && targetSizeUnit + ? targetSizeToBytes(Number(targetSizeInput.value), targetSizeUnit.value) + : undefined; + const files = [...state.files]; + try { - if (state.files.length === 0) { - showAlert('No Files', 'Please select at least one PDF file.'); - hideLoader(); - return; - } - - // Check WASM availability for Condense mode - const algorithm = ( - document.getElementById('compression-algorithm') as HTMLSelectElement - ).value; - if (algorithm === 'condense' && !isPyMuPDFAvailable()) { - showWasmRequiredDialog('pymupdf'); - return; - } - - if (state.files.length === 1) { - const originalFile = state.files[0]; - - let resultBlob: Blob; - let resultSize: number; - let usedMethod: string; - - if (algorithm === 'condense') { - showLoader('Running Condense compression...'); - const result = await performCondenseCompression( - originalFile, - level, - customSettings - ); - resultBlob = result.blob; - resultSize = result.compressedSize; - usedMethod = 'Condense'; - - // Check if fallback was used - if ((result as { usedFallback?: boolean }).usedFallback) { - usedMethod += - ' (without image optimization due to unsupported patterns)'; - } - } else { - showLoader('Running Photon compression...'); - const arrayBuffer = (await readFileAsArrayBuffer( - originalFile - )) as ArrayBuffer; - const resultBytes = await performPhotonCompression( - arrayBuffer, - level, - originalFile - ); - if (!resultBytes) return; - const buffer = resultBytes.buffer.slice( - resultBytes.byteOffset, - resultBytes.byteOffset + resultBytes.byteLength - ) as ArrayBuffer; - resultBlob = new Blob([buffer], { type: 'application/pdf' }); - resultSize = resultBytes.length; - usedMethod = 'Photon'; - } - - const originalSize = formatBytes(originalFile.size); - const compressedSize = formatBytes(resultSize); - const savings = originalFile.size - resultSize; - const savingsPercent = - savings > 0 ? ((savings / originalFile.size) * 100).toFixed(1) : 0; - - downloadFile(resultBlob, originalFile.name); - - hideLoader(); - - if (savings > 0) { - showAlert( - 'Compression Complete', - `Method: ${usedMethod}. File size reduced from ${originalSize} to ${compressedSize} (Saved ${savingsPercent}%).`, - 'success', - () => resetState() - ); - } else { - showAlert( - 'Compression Finished', - `Method: ${usedMethod}. Could not reduce file size further. Original: ${originalSize}, New: ${compressedSize}.`, - 'warning', - () => resetState() - ); - } + if (files.length === 1) { + await compressSingle(files[0], runOptions, targetBytes); } else { - showLoader('Compressing multiple PDFs...'); - const JSZip = (await import('jszip')).default; - const zip = new JSZip(); - let totalOriginalSize = 0; - let totalCompressedSize = 0; - - for (let i = 0; i < state.files.length; i++) { - const file = state.files[i]; - showLoader( - `Compressing ${i + 1}/${state.files.length}: ${file.name}...` - ); - totalOriginalSize += file.size; - - let resultBytes: Uint8Array; - if (algorithm === 'condense') { - const result = await performCondenseCompression( - file, - level, - customSettings - ); - resultBytes = new Uint8Array(await result.blob.arrayBuffer()); - } else { - const arrayBuffer = (await readFileAsArrayBuffer( - file - )) as ArrayBuffer; - const photonResult = await performPhotonCompression( - arrayBuffer, - level, - file - ); - if (!photonResult) return; - resultBytes = photonResult; - } - - totalCompressedSize += resultBytes.length; - zip.file(file.name, resultBytes); - } - - const zipBlob = await zip.generateAsync({ type: 'blob' }); - const totalSavings = totalOriginalSize - totalCompressedSize; - const totalSavingsPercent = - totalSavings > 0 - ? ((totalSavings / totalOriginalSize) * 100).toFixed(1) - : 0; - - downloadFile(zipBlob, 'compressed-pdfs.zip'); - - hideLoader(); - - if (totalSavings > 0) { - showAlert( - 'Compression Complete', - `Compressed ${state.files.length} PDF(s). Total size reduced from ${formatBytes(totalOriginalSize)} to ${formatBytes(totalCompressedSize)} (Saved ${totalSavingsPercent}%).`, - 'success', - () => resetState() - ); - } else { - showAlert( - 'Compression Finished', - `Compressed ${state.files.length} PDF(s). Total size: ${formatBytes(totalCompressedSize)}.`, - 'info', - () => resetState() - ); - } + await compressBatch(files, runOptions, targetBytes); } } catch (e: unknown) { hideLoader(); + if (e instanceof HyperError && e.code === 'decrypt_failed') { + const file = files[0]; + const lock = fileLocks.get(file); + if (lock) { + lock.attempt++; + lock.status = lock.password ? 'wrong' : 'locked'; + } + updateUI(); + showAlert( + 'Password Needed', + `${file.name} is password protected. Enter its password on the file card, then compress again.`, + 'warning', + () => lockViews.get(file)?.input.focus() + ); + return; + } console.error('[CompressPDF] Error:', e); showAlert( 'Error', @@ -556,10 +1046,42 @@ document.addEventListener('DOMContentLoaded', () => { } }; - const handleFileSelect = (files: FileList | null) => { + const handleFileSelect = async (files: ArrayLike<File> | null) => { if (files && files.length > 0) { - state.files = [...state.files, ...Array.from(files)]; + const added: File[] = []; + const rejected: string[] = []; + for (const file of Array.from(files)) { + if (hasPdfHeader(await file.slice(0, 1024).arrayBuffer())) { + added.push(file); + } else { + rejected.push(file.name); + } + } + if (rejected.length > 0) { + showAlert( + 'Not a PDF', + rejected.length === 1 + ? `${rejected[0]} is not a valid PDF, so it was not added.` + : `These files are not valid PDFs, so they were not added: ${rejected.join(', ')}.`, + 'warning' + ); + } + if (added.length === 0) return; + for (const file of added) { + fileLocks.set(file, { status: 'checking', password: '', attempt: 0 }); + } + state.files = [...state.files, ...added]; updateUI(); + + for (const file of added) { + const lock = fileLocks.get(file); + if (!lock) continue; + const opens = await tryPassword(file, ''); + if (fileLocks.get(file) !== lock || lock.status !== 'checking') + continue; + lock.status = opens === false ? 'locked' : 'open'; + if (lock.status === 'locked') updateUI(); + } } }; @@ -584,13 +1106,11 @@ document.addEventListener('DOMContentLoaded', () => { const files = e.dataTransfer?.files; if (files && files.length > 0) { const pdfFiles = Array.from(files).filter( - (f) => f.type === 'application/pdf' + (f) => + f.type === 'application/pdf' || + f.name.toLowerCase().endsWith('.pdf') ); - if (pdfFiles.length > 0) { - const dataTransfer = new DataTransfer(); - pdfFiles.forEach((f) => dataTransfer.items.add(f)); - handleFileSelect(dataTransfer.files); - } + handleFileSelect(pdfFiles); } }); @@ -614,4 +1134,7 @@ document.addEventListener('DOMContentLoaded', () => { if (processBtn) { processBtn.addEventListener('click', compress); } + + applyDefaults(); + createIcons({ icons }); }); diff --git a/src/js/logic/deskew-pdf-page.ts b/src/js/logic/deskew-pdf-page.ts index 0428d704..f5467770 100644 --- a/src/js/logic/deskew-pdf-page.ts +++ b/src/js/logic/deskew-pdf-page.ts @@ -273,7 +273,7 @@ function initPage(): void { if (backBtn) { backBtn.addEventListener('click', () => { - window.location.href = '/'; + window.location.href = import.meta.env.BASE_URL; }); } diff --git a/src/js/logic/digital-sign-pdf-page.ts b/src/js/logic/digital-sign-pdf-page.ts index a2dc02a4..4826fc33 100644 --- a/src/js/logic/digital-sign-pdf-page.ts +++ b/src/js/logic/digital-sign-pdf-page.ts @@ -6,6 +6,7 @@ import { downloadFile, } from '../utils/helpers.js'; import { loadPdfWithPasswordPrompt } from '../utils/password-prompt.js'; +import { sanitizeTranslationHtml } from '../utils/translation-html.js'; import { t } from '../i18n/i18n'; import { signPdf, @@ -404,7 +405,7 @@ async function handleCertFile(file: File): Promise<void> { const certStatus = getElement<HTMLDivElement>('cert-status'); if (certStatus) { - certStatus.innerHTML = `${t('tools:digitalSignPdf.certificateLoaded')} <i data-lucide="check" class="inline w-4 h-4"></i>`; + certStatus.innerHTML = `${sanitizeTranslationHtml(t('tools:digitalSignPdf.certificateLoaded'))} <i data-lucide="check" class="inline w-4 h-4"></i>`; createIcons({ icons }); certStatus.className = 'text-xs text-green-400'; } @@ -559,7 +560,7 @@ async function handlePasswordInput(): Promise<void> { const certStatus = getElement<HTMLDivElement>('cert-status'); if (certStatus) { - certStatus.innerHTML = `${t('tools:digitalSignPdf.certificateUnlocked')} <i data-lucide="check-circle" class="inline w-4 h-4"></i>`; + certStatus.innerHTML = `${sanitizeTranslationHtml(t('tools:digitalSignPdf.certificateUnlocked'))} <i data-lucide="check-circle" class="inline w-4 h-4"></i>`; createIcons({ icons }); certStatus.className = 'text-xs text-green-400'; } diff --git a/src/js/logic/digital-sign-pdf.ts b/src/js/logic/digital-sign-pdf.ts index 59729b5a..9fabc75a 100644 --- a/src/js/logic/digital-sign-pdf.ts +++ b/src/js/logic/digital-sign-pdf.ts @@ -103,8 +103,34 @@ export function parseCombinedPem( * and set VITE_CORS_PROXY_URL environment variable. * * If not set, certificates requiring external chain fetching will fail. + * + * On the official BentoPDF domains we fall back to the project-operated proxy. + * The generated CSP already allows that origin by default (see + * scripts/generate-security-headers.mjs), but the runtime never used it, which + * left the timestamp tool broken on the official HTTPS site. The fallback is + * deliberately scoped to the official domains so self-hosted instances keep + * requiring their own proxy. */ -const CORS_PROXY_URL = import.meta.env.VITE_CORS_PROXY_URL || ''; +const DEFAULT_CORS_PROXY_URL = + 'https://bentopdf-cors-proxy.bentopdf.workers.dev'; + +const OFFICIAL_HOSTNAMES = new Set(['bentopdf.com', 'www.bentopdf.com']); + +function resolveCorsProxyUrl(): string { + const configured = import.meta.env.VITE_CORS_PROXY_URL || ''; + if (configured) { + return configured; + } + + if (typeof window === 'undefined') { + return ''; + } + + const hostname = window.location?.hostname?.toLowerCase() ?? ''; + return OFFICIAL_HOSTNAMES.has(hostname) ? DEFAULT_CORS_PROXY_URL : ''; +} + +const CORS_PROXY_URL = resolveCorsProxyUrl(); /** * Shared secret for signing proxy requests (HMAC-SHA256). diff --git a/src/js/logic/edit-pdf-page.ts b/src/js/logic/edit-pdf-page.ts index 86927cf5..5d4ba933 100644 --- a/src/js/logic/edit-pdf-page.ts +++ b/src/js/logic/edit-pdf-page.ts @@ -15,13 +15,164 @@ const embedPdfWasmUrl = new URL( import type { EmbedPdfContainer } from 'bentopdf-viewer'; import type { + ActiveDocumentSource, AnnotationPluginLite, + AppliedRedactionTracker, DocManagerPlugin, FreeTextSystemFontAnnotation, + RedactionPluginLite, } from '@/types'; const FREETEXT_SUBTYPE = 3; +export function trackAppliedRedactions( + redaction: RedactionPluginLite +): AppliedRedactionTracker { + const pendingByDoc = new Map<string, Map<string, string | undefined>>(); + const appliedByDoc = new Map<string, Map<string, string | undefined>>(); + const lastLeftPendingByDoc = new Map<string, string[]>(); + const getAppliedRedactions = (documentId: string) => { + let applied = appliedByDoc.get(documentId); + if (!applied) { + applied = new Map(); + appliedByDoc.set(documentId, applied); + } + return applied; + }; + + redaction.onPendingChange(({ documentId, pending }) => { + const previous = pendingByDoc.get(documentId) ?? new Map(); + const next = new Map<string, string | undefined>(); + for (const items of Object.values(pending)) { + for (const item of items) { + next.set(item.id, item.text ?? previous.get(item.id)); + } + } + const applied = getAppliedRedactions(documentId); + const leftPending: string[] = []; + for (const [id, text] of previous) { + if (!next.has(id)) { + applied.set(id, text); + leftPending.push(id); + } + } + for (const id of next.keys()) applied.delete(id); + pendingByDoc.set(documentId, next); + lastLeftPendingByDoc.set(documentId, leftPending); + }); + + redaction.onRedactionEvent((event) => { + const applied = appliedByDoc.get(event.documentId); + if (event.type === 'remove' && event.id) { + applied?.delete(event.id); + } else if (event.type === 'clear') { + for (const id of lastLeftPendingByDoc.get(event.documentId) ?? []) { + applied?.delete(id); + } + } + }); + + return { + appliedTexts: (documentId) => [ + ...(appliedByDoc.get(documentId)?.values() ?? []), + ], + forget: (documentId) => { + pendingByDoc.delete(documentId); + appliedByDoc.delete(documentId); + lastLeftPendingByDoc.delete(documentId); + }, + }; +} + +export function redactionScrubTerms( + texts: Iterable<string | undefined> +): string[] { + const terms = new Map<string, string>(); + for (const text of texts) { + for (const line of text?.split(/[\r\n]+/) ?? []) { + const term = line.trim(); + const key = term.toLowerCase(); + if (term && !terms.has(key)) terms.set(key, term); + } + } + return [...terms.values()].sort((a, b) => b.length - a.length); +} + +export async function scrubRedactedText( + bytes: Uint8Array, + terms: string[] +): Promise<Uint8Array> { + const { redactDocumentData } = + await import('../utils/redact-document-data.js'); + return redactDocumentData(bytes, terms); +} + +function confirmChoice( + title: string, + message: string, + confirmLabel: string +): Promise<boolean> { + return new Promise((resolve) => { + const previousActive = document.activeElement as HTMLElement | null; + const overlay = document.createElement('div'); + overlay.className = + 'fixed inset-0 bg-gray-900 bg-opacity-90 flex items-center justify-center z-50'; + overlay.setAttribute('role', 'dialog'); + overlay.setAttribute('aria-modal', 'true'); + overlay.setAttribute('aria-labelledby', 'edit-pdf-confirm-title'); + + const panel = document.createElement('div'); + panel.className = + 'bg-gray-800 rounded-lg shadow-xl p-6 max-w-sm w-full border border-gray-700'; + + const heading = document.createElement('h3'); + heading.id = 'edit-pdf-confirm-title'; + heading.className = 'text-xl font-bold text-white mb-2'; + heading.textContent = title; + + const body = document.createElement('p'); + body.className = 'text-gray-300 mb-6'; + body.textContent = message; + + const cancelBtn = document.createElement('button'); + cancelBtn.type = 'button'; + cancelBtn.className = + 'flex-1 bg-gray-700 hover:bg-gray-600 text-white font-semibold py-2 px-4 rounded-lg transition-colors duration-200'; + cancelBtn.textContent = 'Cancel'; + + const confirmBtn = document.createElement('button'); + confirmBtn.type = 'button'; + confirmBtn.className = + 'flex-1 bg-indigo-600 hover:bg-indigo-700 text-white font-semibold py-2 px-4 rounded-lg transition-colors duration-200'; + confirmBtn.textContent = confirmLabel; + + const actions = document.createElement('div'); + actions.className = 'flex gap-3'; + actions.append(cancelBtn, confirmBtn); + panel.append(heading, body, actions); + overlay.append(panel); + + const close = (result: boolean) => { + overlay.remove(); + document.removeEventListener('keydown', onKeydown); + previousActive?.focus(); + resolve(result); + }; + const onKeydown = (e: KeyboardEvent) => { + if (e.key === 'Escape') close(false); + }; + + cancelBtn.addEventListener('click', () => close(false)); + confirmBtn.addEventListener('click', () => close(true)); + overlay.addEventListener('click', (e) => { + if (e.target === overlay) close(false); + }); + document.addEventListener('keydown', onKeydown); + document.body.append(overlay); + cancelBtn.focus(); + }); +} + function collectAllFreeTexts( annotationPlugin: AnnotationPluginLite | null ): FreeTextSystemFontAnnotation[] { @@ -177,7 +328,10 @@ async function handleFiles(files: FileList) { pdfWrapper.classList.remove('hidden'); const { default: EmbedPDF } = await import('bentopdf-viewer'); - const disabledCategories = getEditorDisabledCategories(); + const disabledCategories = [ + ...getEditorDisabledCategories(), + 'document-export', + ]; viewerInstance = EmbedPDF.init({ disabledCategories, type: 'container', @@ -198,9 +352,24 @@ async function handleFiles(files: FileList) { docManagerPlugin = registry .getPlugin('document-manager') .provides() as unknown as DocManagerPlugin; + const activeDocuments = + docManagerPlugin as unknown as ActiveDocumentSource; + + let redactionPlugin: RedactionPluginLite | null = null; + try { + redactionPlugin = registry + .getPlugin('redaction') + .provides() as unknown as RedactionPluginLite; + } catch { + redactionPlugin = null; + } + const redactionTracker = redactionPlugin + ? trackAppliedRedactions(redactionPlugin) + : null; docManagerPlugin.onDocumentClosed((data: { id?: string }) => { const docId = data?.id || ''; + redactionTracker?.forget(docId); removeFileEntry(docId); }); @@ -258,6 +427,32 @@ async function handleFiles(files: FileList) { downloadBtn.onclick = async () => { try { + const documentId = activeDocuments.getActiveDocumentId(); + const pendingCount = redactionPlugin?.getState().pendingCount ?? 0; + if (redactionPlugin && pendingCount > 0) { + const marks = + pendingCount === 1 + ? "1 redaction mark that hasn't" + : `${pendingCount} redaction marks that haven't`; + const shouldApply = await confirmChoice( + 'Apply redactions?', + `This document has ${marks} been applied. Until a mark is applied, the content under it is still in the file. Apply all marks and download?`, + 'Apply and download' + ); + if (!shouldApply) return; + try { + await redactionPlugin.commitAllPending().toPromise(); + } catch (err) { + console.error('Applying redactions failed:', err); + } + if (redactionPlugin.getState().pendingCount > 0) { + showAlert( + 'Redaction Failed', + 'Some redaction marks could not be applied, so the file was not downloaded. Apply or remove them in the editor and try again.' + ); + return; + } + } const exportPlugin = registry.getPlugin('export').provides(); const arrayBuffer = await exportPlugin.saveAsCopy().toPromise(); let outBytes = new Uint8Array(arrayBuffer); @@ -301,6 +496,42 @@ async function handleFiles(files: FileList) { console.error('Font embed pass failed:', err); } } + const appliedTexts = + documentId && redactionTracker + ? redactionTracker.appliedTexts(documentId) + : []; + if (appliedTexts.length > 0) { + const { PDFDocument } = await import('pdf-lib'); + const exported = await PDFDocument.load(outBytes, { + ignoreEncryption: true, + updateMetadata: false, + }); + if (exported.isEncrypted) { + const shouldDownload = await confirmChoice( + 'Encrypted file', + "Your redactions are applied to the pages, but this file is encrypted, so BentoPDF can't also remove copies of the redacted text from its properties, bookmarks, and attachments. For a full cleanup, redact an unencrypted copy and add a password afterwards with Protect PDF. Download anyway?", + 'Download anyway' + ); + if (!shouldDownload) return; + } else { + showLoader('Removing redacted text from document data...'); + try { + outBytes = await scrubRedactedText( + outBytes, + redactionScrubTerms(appliedTexts) + ); + } catch (err) { + console.error('Redaction cleanup failed:', err); + showAlert( + 'Error', + 'Could not remove the redacted text from the document data, so the file was not downloaded.' + ); + return; + } finally { + hideLoader(); + } + } + } const blob = new Blob([new Uint8Array(outBytes)], { type: 'application/pdf', }); diff --git a/src/js/logic/edit-pdf-text-page.ts b/src/js/logic/edit-pdf-text-page.ts index 02f75e7c..70cca668 100644 --- a/src/js/logic/edit-pdf-text-page.ts +++ b/src/js/logic/edit-pdf-text-page.ts @@ -3,6 +3,11 @@ import { createIcons, icons } from 'lucide'; import { showAlert, showLoader, hideLoader } from '../ui.js'; import { t } from '../i18n/i18n'; import { batchDecryptIfNeeded } from '../utils/password-prompt.js'; +import { + fallbackFontLanguages, + hasFallbackFonts, + loadFallbackFonts, +} from '../utils/font-loader.js'; import { setupFormatDock, setupFindSheet } from './edit-pdf-text-dock'; interface DocDescription { @@ -276,6 +281,8 @@ interface EditorAppModule { getZoom: () => number; getDocDescription: () => DocDescription; setOnSaved: (fn: ((kb: number, fileName: string) => void) | null) => void; + endEdit: (commit: boolean) => void; + saveFile: () => Promise<void>; } function relocateAddText() { @@ -422,6 +429,155 @@ function fitMobileWidth() { mod.setZoom(target); } +function setupFallbackFonts( + app: EditorAppModule, + fonts: Map<string, Uint8Array> +) { + const pending = new Set<Promise<boolean>>(); + let failed = false; + let warned = false; + let loaderShown = false; + let swallowGesture = false; + let resume: (() => void) | null = null; + + const saveIfEnabled = () => { + const save = document.getElementById('save'); + if (save instanceof HTMLButtonElement && !save.disabled) { + void app.saveFile(); + } + }; + const commitEdit = () => app.endEdit(true); + + const track = (text: string | null | undefined) => { + const langs = fallbackFontLanguages(text ?? ''); + if (langs.size === 0 || hasFallbackFonts(fonts, langs)) return; + const load = loadFallbackFonts(fonts, langs); + if (warned) return; + pending.add(load); + void load.then((ok) => { + pending.delete(load); + if (!ok) failed = true; + if (pending.size > 0) return; + const next = resume; + const actionHeld = loaderShown; + resume = null; + if (loaderShown) { + loaderShown = false; + hideLoader(); + } + if (failed) { + failed = false; + if (actionHeld || !warned) { + warned = true; + showAlert( + t('warning.title'), + t('tools:editPdfText.fontsUnavailable'), + 'warning' + ); + } + return; + } + next?.(); + }); + }; + + const hold = (e: Event, next: (() => void) | null) => { + e.preventDefault(); + e.stopImmediatePropagation(); + if (next) resume = next; + if (!loaderShown) { + loaderShown = true; + showLoader(t('tools:editPdfText.loadingFonts')); + } + }; + + void loadFallbackFonts(fonts, ['eng']); + + document.addEventListener( + 'beforeinput', + (e) => track(e.data ?? e.dataTransfer?.getData('text/plain')), + true + ); + + document.addEventListener( + 'paste', + (e) => { + const data = e.clipboardData; + track(data?.getData('text/plain') || data?.getData('text/html')); + const target = e.target; + const intoField = + target instanceof HTMLElement && + (target.isContentEditable || + target instanceof HTMLInputElement || + target instanceof HTMLTextAreaElement); + if (pending.size > 0 && !intoField) hold(e, null); + }, + true + ); + + window.addEventListener( + 'pointerdown', + (e) => { + const target = e.target instanceof Element ? e.target : null; + swallowGesture = + pending.size > 0 && !target?.closest('.editor, .edit-chrome'); + if (!swallowGesture) return; + hold( + e, + target?.closest('#save') + ? saveIfEnabled + : target?.closest('#stage') + ? commitEdit + : null + ); + }, + true + ); + + for (const type of [ + 'mousedown', + 'mouseup', + 'pointerup', + 'click', + 'dblclick', + 'contextmenu', + 'touchstart', + 'touchend', + ]) { + window.addEventListener( + type, + (e) => { + if (!swallowGesture) return; + e.preventDefault(); + e.stopImmediatePropagation(); + if (type === 'click') swallowGesture = false; + }, + { capture: true, passive: false } + ); + } + + window.addEventListener( + 'keydown', + (e) => { + swallowGesture = false; + if (pending.size === 0) return; + const key = e.key.toLowerCase(); + const meta = e.metaKey || e.ctrlKey; + if (meta && key === 's') hold(e, saveIfEnabled); + else if (e.key === 'Escape') hold(e, commitEdit); + else if (meta && (key === 'z' || key === 'y' || key === 'f')) { + hold(e, null); + } else if ( + (e.key === 'Enter' || e.key === ' ') && + e.target instanceof HTMLButtonElement + ) { + hold(e, e.target.id === 'save' ? saveIfEnabled : null); + } + }, + true + ); +} + async function launchEditor(file: File) { if (launching) return; launching = true; @@ -436,6 +592,11 @@ async function launchEditor(file: File) { document.getElementById('text-editor-app')?.removeAttribute('hidden'); if (!appModule) { appModule = (await import('../editcore/app.js')) as EditorAppModule; + const { PdfEngine } = + (await import('../editcore/core.js')) as unknown as { + PdfEngine: { fallbackFonts: Map<string, Uint8Array> }; + }; + setupFallbackFonts(appModule, PdfEngine.fallbackFonts); appModule.setOnSaved((kb) => { showAlert( t('common.success'), diff --git a/src/js/logic/flatten-pdf-page.ts b/src/js/logic/flatten-pdf-page.ts index a42f1614..0440ad5c 100644 --- a/src/js/logic/flatten-pdf-page.ts +++ b/src/js/logic/flatten-pdf-page.ts @@ -1,23 +1,23 @@ import { showAlert } from '../ui.js'; import { downloadFile, formatBytes } from '../utils/helpers.js'; import { batchDecryptIfNeeded } from '../utils/password-prompt.js'; -import { PDFDocument } from 'pdf-lib'; -import { flattenAnnotations } from '../utils/flatten-annotations.js'; +import { + flattenAnnotations, + flattenFormsInDoc, +} from '../utils/flatten-annotations.js'; import { icons, createIcons } from 'lucide'; import JSZip from 'jszip'; import { deduplicateFileName } from '../utils/deduplicate-filename.js'; import { FlattenPdfState } from '@/types'; -import { loadPdfDocument } from '../utils/load-pdf-document.js'; +import { + loadPdfDocument, + removeUnreferencedObjects, +} from '../utils/load-pdf-document.js'; const pageState: FlattenPdfState = { files: [], }; -function flattenFormsInDoc(pdfDoc: PDFDocument) { - const form = pdfDoc.getForm(); - form.flatten(); -} - function resetState() { pageState.files = []; @@ -134,7 +134,7 @@ async function flattenPdf() { console.warn('Could not flatten annotations:', msg); } - const newPdfBytes = await pdfDoc.save(); + const newPdfBytes = await removeUnreferencedObjects(await pdfDoc.save()); downloadFile( new Blob([new Uint8Array(newPdfBytes)], { type: 'application/pdf' }), `flattened_${file.name}` @@ -173,7 +173,9 @@ async function flattenPdf() { console.warn('Could not flatten annotations:', msg); } - const flattenedBytes = await pdfDoc.save(); + const flattenedBytes = await removeUnreferencedObjects( + await pdfDoc.save() + ); const zipEntryName = deduplicateFileName(file.name, usedNames); zip.file(zipEntryName, flattenedBytes); processedCount++; diff --git a/src/js/logic/form-filler-page.ts b/src/js/logic/form-filler-page.ts index ae49b487..47772bdc 100644 --- a/src/js/logic/form-filler-page.ts +++ b/src/js/logic/form-filler-page.ts @@ -311,6 +311,6 @@ document.addEventListener('DOMContentLoaded', () => { processBtn?.addEventListener('click', processAndDownloadForm); backBtn?.addEventListener('click', () => { - window.location.href = '../../index.html'; + window.location.href = import.meta.env.BASE_URL; }); }); diff --git a/src/js/logic/markdown-to-pdf-page.ts b/src/js/logic/markdown-to-pdf-page.ts index e104c983..3282f4cd 100644 --- a/src/js/logic/markdown-to-pdf-page.ts +++ b/src/js/logic/markdown-to-pdf-page.ts @@ -1,21 +1,21 @@ import { MarkdownEditor } from '../utils/markdown-editor.js'; document.addEventListener('DOMContentLoaded', () => { - const container = document.getElementById('markdown-editor-container'); + const container = document.getElementById('markdown-editor-container'); - if (!container) { - console.error('Markdown editor container not found'); - return; - } + if (!container) { + console.error('Markdown editor container not found'); + return; + } - const editor = new MarkdownEditor(container, {}); + const editor = new MarkdownEditor(container, {}); - console.log('Markdown editor initialized'); + console.log('Markdown editor initialized'); - const backButton = document.getElementById('back-to-tools'); - if (backButton) { - backButton.addEventListener('click', () => { - window.location.href = '/'; - }); - } + const backButton = document.getElementById('back-to-tools'); + if (backButton) { + backButton.addEventListener('click', () => { + window.location.href = import.meta.env.BASE_URL; + }); + } }); diff --git a/src/js/logic/merge-pdf-page.ts b/src/js/logic/merge-pdf-page.ts index 18a4303d..94082978 100644 --- a/src/js/logic/merge-pdf-page.ts +++ b/src/js/logic/merge-pdf-page.ts @@ -12,6 +12,7 @@ import { showWasmRequiredDialog, WasmProvider, } from '../utils/wasm-provider.js'; +import { t } from '../i18n/i18n'; import { createIcons, icons } from 'lucide'; import * as pdfjsLib from 'pdfjs-dist'; @@ -199,7 +200,7 @@ async function renderPageMergeThumbnails() { lazyLoadMargin: '300px', onProgress: () => { currentPageNumber++; - showLoader(`Rendering page previews...`); + showLoader(t('tools:mergePdf.loader.renderingPreviews')); }, onBatchComplete: () => { createIcons({ icons }); @@ -216,7 +217,7 @@ async function renderPageMergeThumbnails() { initializePageThumbnailsSortable(); } catch (error) { console.error('Error rendering page thumbnails:', error); - showAlert('Error', 'Failed to render page thumbnails'); + showAlert(t('common.error'), t('tools:mergePdf.alert.thumbnailError')); } finally { hideLoader(); mergeState.isRendering = false; @@ -282,7 +283,7 @@ export async function merge() { return; } - showLoader('Merging PDFs...'); + showLoader(t('tools:mergePdf.loader.merging')); try { const jobs: MergeJob[] = []; const filesToMerge: MergeFile[] = []; @@ -369,7 +370,7 @@ export async function merge() { } if (jobs.length === 0) { - showAlert('Error', 'No files or pages selected to merge.'); + showAlert(t('common.error'), t('tools:mergePdf.alert.noSelection')); hideLoader(); return; } @@ -410,30 +411,29 @@ export async function merge() { downloadFile(blob, 'merged.pdf'); mergeState.mergeSuccess = true; showAlert( - 'Success', - 'PDFs merged successfully!', + t('common.success'), + t('tools:mergePdf.alert.mergeSuccess'), 'success', async () => { await resetState(); } ); } else { + // The worker has no access to i18next: its message is technical and + // English-only, so it goes to the console, not to the user. console.error('Worker merge error:', e.data.message); - showAlert('Error', e.data.message || 'Failed to merge PDFs.'); + showAlert(t('common.error'), t('tools:mergePdf.alert.mergeFailed')); } }; mergeWorker.onerror = (e) => { hideLoader(); console.error('Worker error:', e); - showAlert('Error', 'An unexpected error occurred in the merge worker.'); + showAlert(t('common.error'), t('tools:mergePdf.alert.workerError')); }; } catch (e) { console.error('Merge error:', e); - showAlert( - 'Error', - 'Failed to merge PDFs. Please check that all files are valid and not password-protected.' - ); + showAlert(t('common.error'), t('tools:mergePdf.alert.mergeError')); hideLoader(); } } @@ -447,14 +447,14 @@ export async function refreshMergeUI() { const wasInPageMode = mergeState.activeMode === 'page'; - showLoader('Loading PDF documents...'); + showLoader(t('tools:mergePdf.loader.loadingDocuments')); try { mergeState.pdfDocs = {}; mergeState.pdfBytes = {}; hideLoader(); state.files = await batchDecryptIfNeeded(state.files); - showLoader('Loading PDF documents...'); + showLoader(t('tools:mergePdf.loader.loadingDocuments')); for (let i = 0; i < state.files.length; i++) { const file = state.files[i]; @@ -472,7 +472,7 @@ export async function refreshMergeUI() { } } catch (error) { console.error('Error loading PDFs:', error); - showAlert('Error', 'Failed to load one or more PDF files'); + showAlert(t('common.error'), t('tools:mergePdf.alert.loadError')); return; } finally { hideLoader(); diff --git a/src/js/logic/pdf-to-docx-page.ts b/src/js/logic/pdf-to-word-page.ts similarity index 96% rename from src/js/logic/pdf-to-docx-page.ts rename to src/js/logic/pdf-to-word-page.ts index 49b9a8fb..c79e6e45 100644 --- a/src/js/logic/pdf-to-docx-page.ts +++ b/src/js/logic/pdf-to-word-page.ts @@ -9,6 +9,7 @@ import { import { state } from '../state.js'; import { createIcons, icons } from 'lucide'; import { loadPyMuPDF } from '../utils/pymupdf-loader.js'; +import { normalizeCmykForDocx } from '../utils/ghostscript-loader.js'; import { batchDecryptIfNeeded } from '../utils/password-prompt.js'; import { deduplicateFileName } from '../utils/deduplicate-filename.js'; @@ -112,7 +113,9 @@ document.addEventListener('DOMContentLoaded', () => { const file = state.files[0]; showLoader(`Converting ${file.name}...`); - const docxBlob = await pymupdf.pdfToDocx(file); + const docxBlob = await pymupdf.pdfToDocx( + await normalizeCmykForDocx(file) + ); const outName = file.name.replace(/\.pdf$/i, '') + '.docx'; downloadFile(docxBlob, outName); @@ -135,7 +138,9 @@ document.addEventListener('DOMContentLoaded', () => { `Converting ${i + 1}/${state.files.length}: ${file.name}...` ); - const docxBlob = await pymupdf.pdfToDocx(file); + const docxBlob = await pymupdf.pdfToDocx( + await normalizeCmykForDocx(file) + ); const baseName = file.name.replace(/\.pdf$/i, ''); const arrayBuffer = await docxBlob.arrayBuffer(); const zipEntryName = deduplicateFileName( diff --git a/src/js/logic/pdf-workflow-page.ts b/src/js/logic/pdf-workflow-page.ts index 68c470ce..09b200f0 100644 --- a/src/js/logic/pdf-workflow-page.ts +++ b/src/js/logic/pdf-workflow-page.ts @@ -43,7 +43,7 @@ import { templateNameExists, deleteTemplate, } from '@/js/workflow/serialization'; -import { getAvailableTesseractLanguageEntries } from '@/js/utils/tesseract-language-availability.js'; +import { getControlSpec } from '@/js/workflow/nodes/control-specs'; import { isToolDisabled } from '@/js/utils/disabled-tools.js'; import { IMAGE_ACCEPT } from '@/js/utils/image-input-utils.js'; @@ -1124,181 +1124,8 @@ function showNodeSettings(node: BaseWorkflowNode) { return; } - const dropdownOptions: Record<string, { label: string; value: string }[]> = { - format: [ - { label: 'JPG', value: 'jpg' }, - { label: 'PNG', value: 'png' }, - { label: 'WebP', value: 'webp' }, - { label: 'SVG', value: 'svg' }, - ], - position: [ - { label: 'Bottom Center', value: 'bottom-center' }, - { label: 'Bottom Left', value: 'bottom-left' }, - { label: 'Bottom Right', value: 'bottom-right' }, - { label: 'Top Center', value: 'top-center' }, - { label: 'Top Left', value: 'top-left' }, - { label: 'Top Right', value: 'top-right' }, - ], - orientation: [ - { label: 'Auto (Keep Original)', value: 'auto' }, - { label: 'Portrait', value: 'portrait' }, - { label: 'Landscape', value: 'landscape' }, - ], - direction: [ - { label: 'Vertical', value: 'vertical' }, - { label: 'Horizontal', value: 'horizontal' }, - ], - pagesPerSheet: [ - { label: '2', value: '2' }, - { label: '4', value: '4' }, - { label: '9', value: '9' }, - { label: '16', value: '16' }, - ], - fontFamily: [ - { label: 'Helvetica', value: 'helv' }, - { label: 'Times Roman', value: 'times' }, - { label: 'Courier', value: 'cour' }, - { label: 'Times Italic', value: 'tiro' }, - ], - pageSize: [ - { label: 'A4', value: 'a4' }, - { label: 'Letter', value: 'letter' }, - { label: 'Legal', value: 'legal' }, - ], - targetSize: [ - { label: 'A4', value: 'A4' }, - { label: 'Letter', value: 'Letter' }, - { label: 'Legal', value: 'Legal' }, - { label: 'A3', value: 'A3' }, - { label: 'A5', value: 'A5' }, - { label: 'Tabloid', value: 'Tabloid' }, - { label: 'Custom', value: 'Custom' }, - ], - scalingMode: [ - { label: 'Fit (keep full page visible)', value: 'fit' }, - { label: 'Fill (cover full target page)', value: 'fill' }, - ], - customUnits: [ - { label: 'Millimeters (mm)', value: 'mm' }, - { label: 'Inches (in)', value: 'in' }, - ], - numberFormat: [ - { label: 'Simple (1, 2, 3)', value: 'simple' }, - { label: 'Page X of Y', value: 'page_x_of_y' }, - ], - angle: [ - { label: '90° Clockwise', value: '90' }, - { label: '180°', value: '180' }, - { label: '90° Counter-clockwise', value: '270' }, - ], - blankPosition: [ - { label: 'End', value: 'end' }, - { label: 'Beginning', value: 'start' }, - { label: 'After Page...', value: 'after' }, - ], - resolution: [ - { label: 'Standard (192 DPI)', value: '2.0' }, - { label: 'High (288 DPI)', value: '3.0' }, - { label: 'Ultra (384 DPI)', value: '4.0' }, - ], - language: getAvailableTesseractLanguageEntries().map(([code, name]) => ({ - label: name, - value: code, - })), - gridMode: [ - { label: '1x2 (Booklet)', value: '1x2' }, - { label: '2x2 (4-up)', value: '2x2' }, - { label: '2x4 (8-up)', value: '2x4' }, - { label: '4x4 (16-up)', value: '4x4' }, - ], - paperSize: [ - { label: 'Letter', value: 'Letter' }, - { label: 'A4', value: 'A4' }, - { label: 'A3', value: 'A3' }, - { label: 'Tabloid', value: 'Tabloid' }, - { label: 'Legal', value: 'Legal' }, - ], - rasterizeDpi: [ - { label: '72 (Screen)', value: '72' }, - { label: '150 (Default)', value: '150' }, - { label: '200 (Good)', value: '200' }, - { label: '300 (Print)', value: '300' }, - { label: '600 (High Quality)', value: '600' }, - ], - imageFormat: [ - { label: 'PNG (Lossless)', value: 'png' }, - { label: 'JPEG (Smaller file size)', value: 'jpeg' }, - ], - skewThreshold: [ - { label: '0.1° (Very Sensitive)', value: '0.1' }, - { label: '0.5° (Default)', value: '0.5' }, - { label: '1.0° (Normal)', value: '1.0' }, - { label: '2.0° (Less Sensitive)', value: '2.0' }, - ], - processingDpi: [ - { label: '100 (Fast)', value: '100' }, - { label: '150 (Default)', value: '150' }, - { label: '200 (Better)', value: '200' }, - { label: '300 (Best Quality)', value: '300' }, - ], - level: [ - { label: 'PDF/A-1b (Strict, no transparency)', value: 'PDF/A-1b' }, - { label: 'PDF/A-2b (Recommended)', value: 'PDF/A-2b' }, - { label: 'PDF/A-3b (Modern, allows attachments)', value: 'PDF/A-3b' }, - ], - algorithm: [ - { label: 'Condense (Smart, requires PyMuPDF)', value: 'condense' }, - { label: 'Photon (Rasterize pages)', value: 'photon' }, - ], - compressionLevel: [ - { label: 'Light', value: 'light' }, - { label: 'Balanced', value: 'balanced' }, - { label: 'Aggressive', value: 'aggressive' }, - { label: 'Extreme', value: 'extreme' }, - ], - redactMode: [ - { label: 'Search Text', value: 'text' }, - { label: 'Area (Coordinates)', value: 'area' }, - ], - }; - - const booleanControls = new Set([ - 'grayscale', - 'border', - 'margins', - 'separator', - 'sepia', - 'includeCcBcc', - 'includeAttachments', - 'binarize', - 'preFlatten', - 'flattenForms', - 'removeMetadata', - 'removeAnnotations', - 'removeJavascript', - 'removeEmbeddedFiles', - 'removeLayers', - 'removeLinks', - 'removeStructureTree', - 'removeMarkInfo', - 'removeFonts', - 'subsetFonts', - 'convertToGrayscale', - 'removeThumbnails', - 'retainPageLabels', - ]); - const multiSelectDropdowns = new Set(['language']); const advancedControls = new Set(['resolution', 'binarize', 'whitelist']); - const colorControls = new Set([ - 'color', - 'borderColor', - 'backgroundColor', - 'separatorColor', - 'fontColor', - 'fillColor', - ]); - const controlHints: Record<string, string> = { pages: 'e.g. 1-3, 5, 7-9', whitelist: 'Limit recognized characters (leave empty for all)', @@ -1307,6 +1134,8 @@ function showNodeSettings(node: BaseWorkflowNode) { y0: 'Top edge in points', x1: 'Right edge in points', y1: 'Bottom edge in points', + targetKilobytes: + 'Finds the best quality under this size. 0 means no target.', retainPageLabels: "Off (default): natural 1–N numbering. On: each file's original page labels are preserved (may produce duplicate labels).", }; @@ -1333,15 +1162,17 @@ function showNodeSettings(node: BaseWorkflowNode) { controlWrappers[key] = wrapper; const ctrl = control as { value?: unknown; type?: string }; const currentValue = String(ctrl.value ?? ''); + const spec = getControlSpec(node.nodeType, key); const controlLabel = document.createElement('label'); controlLabel.className = 'block text-xs text-gray-400 mb-1'; controlLabel.textContent = formatLabel(key); wrapper.appendChild(controlLabel); - if (dropdownOptions[key] && multiSelectDropdowns.has(key)) { + if (spec?.kind === 'multiselect') { + const { options, separator } = spec; const selectedValues = new Set( - currentValue ? currentValue.split('+') : [] + currentValue ? currentValue.split(separator) : [] ); const container = document.createElement('div'); container.className = 'flex flex-col gap-1'; @@ -1364,7 +1195,7 @@ function showNodeSettings(node: BaseWorkflowNode) { function updateTags() { tagsDiv.innerHTML = ''; for (const val of selectedValues) { - const opt = dropdownOptions[key].find((o) => o.value === val); + const opt = options.find((o) => o.value === val); if (!opt) continue; const tag = document.createElement('span'); tag.className = @@ -1388,13 +1219,13 @@ function showNodeSettings(node: BaseWorkflowNode) { function updateCtrl() { (ctrl as { value: string }).value = - Array.from(selectedValues).join('+'); + Array.from(selectedValues).join(separator); } function renderList(filter: string) { listDiv.innerHTML = ''; const lowerFilter = filter.toLowerCase(); - for (const opt of dropdownOptions[key]) { + for (const opt of options) { if (lowerFilter && !opt.label.toLowerCase().includes(lowerFilter)) continue; const label = document.createElement('label'); @@ -1427,10 +1258,10 @@ function showNodeSettings(node: BaseWorkflowNode) { updateTags(); renderList(''); wrapper.appendChild(container); - } else if (dropdownOptions[key]) { + } else if (spec?.kind === 'select') { const select = document.createElement('select'); select.className = inputClass; - for (const opt of dropdownOptions[key]) { + for (const opt of spec.options) { const option = document.createElement('option'); option.value = opt.value; option.textContent = opt.label; @@ -1438,13 +1269,14 @@ function showNodeSettings(node: BaseWorkflowNode) { select.appendChild(option); } select.addEventListener('change', () => { - (ctrl as { value: string }).value = select.value; + ctrl.value = + typeof ctrl.value === 'number' ? Number(select.value) : select.value; if (conditionalVisibility[key]) { applyConditionalVisibility(key, select.value); } }); wrapper.appendChild(select); - } else if (booleanControls.has(key)) { + } else if (spec?.kind === 'boolean') { const toggle = document.createElement('button'); toggle.type = 'button'; const isOn = currentValue === 'true'; @@ -1461,7 +1293,7 @@ function showNodeSettings(node: BaseWorkflowNode) { dot.className = `pointer-events-none absolute top-[3px] left-[3px] h-[18px] w-[18px] rounded-full bg-white shadow-md transition-transform duration-200 ${on ? 'translate-x-5' : 'translate-x-0'}`; }); wrapper.appendChild(toggle); - } else if (colorControls.has(key)) { + } else if (spec?.kind === 'color') { const colorRow = document.createElement('div'); colorRow.className = 'flex items-center gap-2'; const colorInput = document.createElement('input'); @@ -1489,18 +1321,25 @@ function showNodeSettings(node: BaseWorkflowNode) { const input = document.createElement('input'); input.type = 'number'; input.className = inputClass; + if (spec?.kind === 'number') { + input.min = String(spec.min); + input.max = String(spec.max); + input.step = spec.step === undefined ? 'any' : String(spec.step); + } input.value = currentValue; input.addEventListener('input', () => { const num = parseFloat(input.value); if (!isNaN(num)) { - (ctrl as { value: number }).value = num; + ctrl.value = node.sanitizeControlValue(key, num); } }); + input.addEventListener('change', () => { + input.value = String(ctrl.value ?? ''); + }); wrapper.appendChild(input); } else { const input = document.createElement('input'); - const isPasswordField = key === 'password' || key === 'ownerPassword'; - input.type = isPasswordField ? 'password' : 'text'; + input.type = spec?.kind === 'password' ? 'password' : 'text'; input.className = inputClass; input.value = currentValue; input.addEventListener('input', () => { diff --git a/src/js/logic/encrypt-pdf-page.ts b/src/js/logic/protect-pdf-page.ts similarity index 100% rename from src/js/logic/encrypt-pdf-page.ts rename to src/js/logic/protect-pdf-page.ts diff --git a/src/js/logic/remove-annotations-page.ts b/src/js/logic/remove-annotations-page.ts index bea92451..e190eebd 100644 --- a/src/js/logic/remove-annotations-page.ts +++ b/src/js/logic/remove-annotations-page.ts @@ -1,7 +1,10 @@ import { PDFDocument, PDFName } from 'pdf-lib'; import { createIcons, icons } from 'lucide'; import { loadPdfWithPasswordPrompt } from '../utils/password-prompt.js'; -import { loadPdfDocument } from '../utils/load-pdf-document.js'; +import { + loadPdfDocument, + removeUnreferencedObjects, +} from '../utils/load-pdf-document.js'; import { escapeHtml } from '../utils/helpers.js'; // State management @@ -142,7 +145,9 @@ async function processRemoveAnnotations() { } } - const newPdfBytes = await pageState.pdfDoc.save(); + const newPdfBytes = await removeUnreferencedObjects( + await pageState.pdfDoc.save() + ); downloadFile( new Blob([new Uint8Array(newPdfBytes)], { type: 'application/pdf' }), pageState.file?.name || 'document.pdf' @@ -189,6 +194,6 @@ document.addEventListener('DOMContentLoaded', () => { processBtn?.addEventListener('click', processRemoveAnnotations); backBtn?.addEventListener('click', () => { - window.location.href = '../../index.html'; + window.location.href = import.meta.env.BASE_URL; }); }); diff --git a/src/js/logic/remove-blank-pages-page.ts b/src/js/logic/remove-blank-pages-page.ts index 5b70b371..540e37c6 100644 --- a/src/js/logic/remove-blank-pages-page.ts +++ b/src/js/logic/remove-blank-pages-page.ts @@ -366,6 +366,6 @@ document.addEventListener('DOMContentLoaded', () => { processBtn?.addEventListener('click', processRemoveBlankPages); document.getElementById('back-to-tools')?.addEventListener('click', () => { - window.location.href = '../../index.html'; + window.location.href = import.meta.env.BASE_URL; }); }); diff --git a/src/js/logic/remove-metadata-page.ts b/src/js/logic/remove-metadata-page.ts index ab5a60f8..d816456d 100644 --- a/src/js/logic/remove-metadata-page.ts +++ b/src/js/logic/remove-metadata-page.ts @@ -3,7 +3,10 @@ import { downloadFile, formatBytes } from '../utils/helpers.js'; import { PDFDocument, PDFName } from 'pdf-lib'; import { icons, createIcons } from 'lucide'; import { loadPdfWithPasswordPrompt } from '../utils/password-prompt.js'; -import { loadPdfDocument } from '../utils/load-pdf-document.js'; +import { + loadPdfDocument, + removeUnreferencedObjects, +} from '../utils/load-pdf-document.js'; interface PageState { file: File | null; @@ -155,9 +158,9 @@ async function removeMetadata() { removeMetadataFromDoc(pdfDoc); - const newPdfBytes = await pdfDoc.save(); + const newPdfBytes = await removeUnreferencedObjects(await pdfDoc.save()); downloadFile( - new Blob([newPdfBytes as BlobPart], { type: 'application/pdf' }), + new Blob([new Uint8Array(newPdfBytes)], { type: 'application/pdf' }), pageState.file?.name || 'document.pdf' ); showAlert('Success', 'Metadata removed successfully!', 'success', () => { diff --git a/src/js/logic/sanitize-pdf-page.ts b/src/js/logic/sanitize-pdf-page.ts index a758b4f2..739a3f2d 100644 --- a/src/js/logic/sanitize-pdf-page.ts +++ b/src/js/logic/sanitize-pdf-page.ts @@ -149,14 +149,25 @@ async function runSanitize() { new Blob([new Uint8Array(result.bytes)], { type: 'application/pdf' }), pageState.file?.name || 'document.pdf' ); - showAlert( - 'Success', - 'PDF has been sanitized and downloaded.', - 'success', - () => { - resetState(); - } - ); + if (result.skipped.length > 0) { + showAlert( + 'Sanitized With Warnings', + `The PDF was downloaded, but these steps could not be completed: ${result.skipped.join(', ')}. The content they cover may still be in the file.`, + 'warning', + () => { + resetState(); + } + ); + } else { + showAlert( + 'Success', + 'PDF has been sanitized and downloaded.', + 'success', + () => { + resetState(); + } + ); + } } catch (e: unknown) { console.error('Sanitization Error:', e); const msg = e instanceof Error ? e.message : String(e); diff --git a/src/js/logic/sign-pdf-page.ts b/src/js/logic/sign-pdf-page.ts index d2de5caa..232e8c09 100644 --- a/src/js/logic/sign-pdf-page.ts +++ b/src/js/logic/sign-pdf-page.ts @@ -7,8 +7,14 @@ import { } from '../utils/helpers.js'; import { loadPdfWithPasswordPrompt } from '../utils/password-prompt.js'; import { t } from '../i18n/i18n'; -import { loadPdfDocument } from '../utils/load-pdf-document.js'; -import { flattenAnnotations } from '../utils/flatten-annotations.js'; +import { + loadPdfDocument, + removeUnreferencedObjects, +} from '../utils/load-pdf-document.js'; +import { + flattenAnnotations, + flattenFormsInDoc, +} from '../utils/flatten-annotations.js'; import type { SignState, PDFViewerWindow } from '@/types'; const signState: SignState = { @@ -285,7 +291,7 @@ async function applyAndSaveSignatures() { const pdfBytes = new Uint8Array(rawPdfBytes); const pdfDoc = await loadPdfDocument(pdfBytes); try { - pdfDoc.getForm().flatten(); + flattenFormsInDoc(pdfDoc); } catch (e: unknown) { const msg = e instanceof Error ? e.message : String(e); if (!msg.includes('getForm')) { @@ -298,7 +304,9 @@ async function applyAndSaveSignatures() { const msg = e instanceof Error ? e.message : String(e); console.warn('Could not flatten annotations:', msg); } - const flattenedPdfBytes = await pdfDoc.save(); + const flattenedPdfBytes = await removeUnreferencedObjects( + await pdfDoc.save() + ); const blob = new Blob([new Uint8Array(flattenedPdfBytes)], { type: 'application/pdf', diff --git a/src/js/logic/decrypt-pdf-page.ts b/src/js/logic/unlock-pdf-page.ts similarity index 100% rename from src/js/logic/decrypt-pdf-page.ts rename to src/js/logic/unlock-pdf-page.ts diff --git a/src/js/logic/validate-signature-pdf-page.ts b/src/js/logic/validate-signature-pdf-page.ts index b67fbb53..2dfa512e 100644 --- a/src/js/logic/validate-signature-pdf-page.ts +++ b/src/js/logic/validate-signature-pdf-page.ts @@ -377,9 +377,9 @@ function createSignatureCard( result.cryptoVerified === true && result.coverageStatus === 'partial' ) { - statusColor = 'text-red-400'; - statusIcon = 'x-circle'; - statusText = 'Invalid — Modified After Signing (partial coverage)'; + statusColor = 'text-yellow-400'; + statusIcon = 'alert-triangle'; + statusText = 'Modified After Signing (signed part intact)'; } else { statusColor = 'text-red-400'; statusIcon = 'x-circle'; diff --git a/src/js/logic/validate-signature-pdf.ts b/src/js/logic/validate-signature-pdf.ts index 7525aae7..cb77ca51 100644 --- a/src/js/logic/validate-signature-pdf.ts +++ b/src/js/logic/validate-signature-pdf.ts @@ -1,82 +1,319 @@ import forge from 'node-forge'; -import { ExtractedSignature, SignatureValidationResult } from '@/types'; +import { SignatureValidationResult } from '@/types'; +import type { ExtractedSignatureWithError, PdfValueSpan } from '@/types'; const INSECURE_DIGEST_OIDS = new Set<string>([ '1.2.840.113549.2.5', '1.3.14.3.2.26', ]); -export function extractSignatures(pdfBytes: Uint8Array): ExtractedSignature[] { - const signatures: ExtractedSignature[] = []; +const PDF_WHITESPACE = new Set('\0\t\n\f\r '); +const PDF_TOKEN_TERMINATORS = new Set('\0\t\n\f\r ()<>[]{}/%'); +const PDF_STRING_ESCAPES: Record<string, string> = { + n: '\n', + r: '\r', + t: '\t', + b: '\b', + f: '\f', + '\r\n': '', + '\r': '', + '\n': '', +}; + +export function extractSignatures( + pdfBytes: Uint8Array +): ExtractedSignatureWithError[] { + const signatures: ExtractedSignatureWithError[] = []; const pdfString = new TextDecoder('latin1').decode(pdfBytes); - // Find all signature objects for /Type /Sig - const sigRegex = /\/Type\s*\/Sig\b/g; - let sigMatch; - let sigIndex = 0; + // Find all signature dictionaries (see isSignatureDictionary) + const objectHeader = /\d+\s+\d+\s+obj\b/g; + let endobj = -1; - while ((sigMatch = sigRegex.exec(pdfString)) !== null) { - try { - const searchStart = Math.max(0, sigMatch.index - 5000); - const searchEnd = Math.min(pdfString.length, sigMatch.index + 10000); - const context = pdfString.substring(searchStart, searchEnd); - const byteRangeMatch = context.match( - /\/ByteRange\s*\[\s*(\d+)\s+(\d+)\s+(\d+)\s+(\d+)\s*\]/ - ); - if (!byteRangeMatch) continue; + while (objectHeader.exec(pdfString) !== null) { + const start = objectHeader.lastIndex; + if (endobj < start) { + endobj = pdfString.indexOf('endobj', start); + if (endobj < 0) endobj = pdfString.length; + } + const body = pdfString.slice(0, endobj); + const dictionaries: Map<string, PdfValueSpan>[] = []; + const end = parsePdfValue( + body, + skipPdfWhitespace(body, start), + dictionaries, + 0 + ); + objectHeader.lastIndex = end < 0 ? endobj : end; + if (end < 0) continue; - const byteRange = [ - parseInt(byteRangeMatch[1], 10), - parseInt(byteRangeMatch[2], 10), - parseInt(byteRangeMatch[3], 10), - parseInt(byteRangeMatch[4], 10), - ]; - - const contentsMatch = context.match(/\/Contents\s*<([0-9A-Fa-f]+)>/); - if (!contentsMatch) continue; - - const hexContents = contentsMatch[1]; - const contentsBytes = hexToBytes(hexContents); - - const reasonMatch = context.match(/\/Reason\s*\(([^)]*)\)/); - const locationMatch = context.match(/\/Location\s*\(([^)]*)\)/); - const contactMatch = context.match(/\/ContactInfo\s*\(([^)]*)\)/); - const nameMatch = context.match(/\/Name\s*\(([^)]*)\)/); - const timeMatch = context.match( - /\/M\s*\(D:(\d{4})(\d{2})(\d{2})(\d{2})(\d{2})(\d{2})/ - ); - - let signingTime: string | undefined; - if (timeMatch) { - signingTime = `${timeMatch[1]}-${timeMatch[2]}-${timeMatch[3]}T${timeMatch[4]}:${timeMatch[5]}:${timeMatch[6]}`; + for (const entries of dictionaries) { + if (isSignatureDictionary(pdfString, entries)) { + signatures.push( + readSignatureDictionary( + pdfString, + pdfBytes, + entries, + signatures.length + ) + ); } - - signatures.push({ - index: sigIndex++, - contents: contentsBytes, - byteRange, - reason: reasonMatch - ? decodeURIComponent(escape(reasonMatch[1])) - : undefined, - location: locationMatch - ? decodeURIComponent(escape(locationMatch[1])) - : undefined, - contactInfo: contactMatch - ? decodeURIComponent(escape(contactMatch[1])) - : undefined, - name: nameMatch ? decodeURIComponent(escape(nameMatch[1])) : undefined, - signingTime, - }); - } catch (e) { - console.warn('Error extracting signature at index', sigIndex, e); } } return signatures; } +/** + * /Type is optional in a signature dictionary (PDF 32000-1, 12.8.1, Table + * 252), and some signers leave it out, so a dictionary without /Type that + * carries both /ByteRange and /Contents is a signature too. Document + * timestamps (/Type /DocTimeStamp, /SubFilter /ETSI.RFC3161) hold an RFC 3161 + * token rather than a CMS signature over the document and stay out. + */ +function isSignatureDictionary( + pdfString: string, + entries: Map<string, PdfValueSpan> +): boolean { + const name = (key: string) => { + const span = entries.get(key); + return span ? pdfString.slice(span.start, span.end) : undefined; + }; + const type = name('Type'); + if (type !== undefined) return type === '/Sig'; + return ( + entries.has('ByteRange') && + entries.has('Contents') && + name('SubFilter') !== '/ETSI.RFC3161' + ); +} + +function readSignatureDictionary( + pdfString: string, + pdfBytes: Uint8Array, + entries: Map<string, PdfValueSpan>, + index: number +): ExtractedSignatureWithError { + const text = (key: string) => decodePdfText(pdfBytes, entries.get(key)); + const timeMatch = text('M')?.match( + /^D:(\d{4})(\d{2})(\d{2})(\d{2})(\d{2})(\d{2})/ + ); + const signature: ExtractedSignatureWithError = { + index, + contents: new Uint8Array(0), + byteRange: [], + reason: text('Reason'), + location: text('Location'), + contactInfo: text('ContactInfo'), + name: text('Name'), + signingTime: timeMatch + ? `${timeMatch[1]}-${timeMatch[2]}-${timeMatch[3]}T${timeMatch[4]}:${timeMatch[5]}:${timeMatch[6]}` + : undefined, + }; + + const rangeSpan = entries.get('ByteRange'); + const rangeMatch = rangeSpan + ? /^\[\s*(\d+)\s+(\d+)\s+(\d+)\s+(\d+)\s*\]$/.exec( + pdfString.slice(rangeSpan.start, rangeSpan.end) + ) + : null; + if (!rangeMatch) { + signature.extractionError = + 'Signature has a missing or malformed /ByteRange'; + return signature; + } + + signature.byteRange = rangeMatch.slice(1).map(Number); + const byteRangeError = getByteRangeError( + signature.byteRange, + pdfBytes.length + ); + if (byteRangeError) { + signature.extractionError = byteRangeError; + return signature; + } + + const gapStart = signature.byteRange[0] + signature.byteRange[1]; + const gapEnd = signature.byteRange[2]; + const gap = pdfString.slice(gapStart, gapEnd); + if (gap.length % 2 !== 0 || !/^<[0-9A-Fa-f]+>$/.test(gap)) { + signature.extractionError = + 'The bytes excluded by /ByteRange are not exactly a hex-encoded signature'; + return signature; + } + + const contentsSpan = entries.get('Contents'); + if (contentsSpan?.start !== gapStart || contentsSpan.end !== gapEnd) { + signature.extractionError = + 'The bytes excluded by /ByteRange are not the /Contents of this signature'; + return signature; + } + + const contents = hexToBytes(gap.slice(1, -1)); + const derObjectLength = getDerObjectLength(contents); + if ( + derObjectLength === null || + derObjectLength > contents.length || + contents.subarray(derObjectLength).some((byte) => byte !== 0) + ) { + signature.extractionError = + 'Signature /Contents is not a single DER object followed by zero padding'; + return signature; + } + + signature.contents = contents.slice(0, derObjectLength); + return signature; +} + +function getByteRangeError( + byteRange: number[], + fileLength: number +): string | null { + if ( + byteRange.length !== 4 || + !byteRange.every((value) => Number.isSafeInteger(value) && value >= 0) + ) { + return 'Malformed ByteRange'; + } + const [start1, len1, start2, len2] = byteRange; + if (start1 !== 0) { + return 'ByteRange does not start at the beginning of the file'; + } + if (start1 + len1 > start2 || start2 + len2 > fileLength) { + return 'ByteRange out of bounds'; + } + return null; +} + +function skipPdfWhitespace(text: string, pos: number): number { + while (pos < text.length) { + if (text[pos] === '%') { + while (pos < text.length && text[pos] !== '\n' && text[pos] !== '\r') { + pos++; + } + } else if (PDF_WHITESPACE.has(text[pos])) { + pos++; + } else { + break; + } + } + return pos; +} + +function findTokenEnd(text: string, pos: number): number { + while (pos < text.length && !PDF_TOKEN_TERMINATORS.has(text[pos])) pos++; + return pos; +} + +function findLiteralStringEnd(text: string, start: number): number { + let depth = 0; + for (let pos = start; pos < text.length; pos++) { + if (text[pos] === '\\') { + pos++; + } else if (text[pos] === '(') { + depth++; + } else if (text[pos] === ')') { + depth--; + if (depth === 0) return pos + 1; + } + } + return -1; +} + +function parsePdfValue( + text: string, + start: number, + dictionaries: Map<string, PdfValueSpan>[], + depth: number +): number { + if (depth > 32 || start >= text.length) return -1; + + if (text.startsWith('<<', start)) { + const entries = new Map<string, PdfValueSpan>(); + dictionaries.push(entries); + let pos = skipPdfWhitespace(text, start + 2); + while (!text.startsWith('>>', pos)) { + const keyEnd = text[pos] === '/' ? findTokenEnd(text, pos + 1) : pos; + const valueStart = skipPdfWhitespace(text, keyEnd); + const valueEnd = parsePdfValue(text, valueStart, dictionaries, depth + 1); + if (valueEnd < 0) return -1; + if (keyEnd > pos) { + entries.set(text.slice(pos + 1, keyEnd), { + start: valueStart, + end: valueEnd, + }); + } + pos = skipPdfWhitespace(text, valueEnd); + } + return pos + 2; + } + + if (text[start] === '<') { + const end = text.indexOf('>', start); + return end < 0 ? -1 : end + 1; + } + + if (text[start] === '(') return findLiteralStringEnd(text, start); + + if (text[start] === '[') { + let pos = skipPdfWhitespace(text, start + 1); + while (text[pos] !== ']') { + const end = parsePdfValue(text, pos, dictionaries, depth + 1); + if (end < 0) return -1; + pos = skipPdfWhitespace(text, end); + } + return pos + 1; + } + + const end = findTokenEnd(text, text[start] === '/' ? start + 1 : start); + return end > start ? end : -1; +} + +function decodePdfText( + pdfBytes: Uint8Array, + span: PdfValueSpan | undefined +): string | undefined { + if (!span) return undefined; + const raw = uint8ToLatin1(pdfBytes.subarray(span.start, span.end)); + let text: string; + if (raw.startsWith('(')) { + text = raw + .slice(1, -1) + .replace(/\\([0-7]{1,3}|\r\n|[\s\S])/g, (_, escaped: string) => + /^[0-7]/.test(escaped) + ? String.fromCharCode(parseInt(escaped, 8) & 0xff) + : (PDF_STRING_ESCAPES[escaped] ?? escaped) + ); + } else if (/^<[0-9A-Fa-f\s]*>$/.test(raw)) { + const hex = raw.slice(1, -1).replace(/\s/g, ''); + text = uint8ToLatin1(hexToBytes(hex.length % 2 ? hex + '0' : hex)); + } else { + return undefined; + } + + const bytes = latin1ToUint8(text); + if (text.startsWith('\xfe\xff')) { + return new TextDecoder('utf-16be').decode(bytes.subarray(2)); + } + try { + return new TextDecoder('utf-8', { fatal: true }).decode(bytes); + } catch { + return text; + } +} + +function getDerObjectLength(bytes: Uint8Array): number | null { + if (bytes.length < 2) return null; + if (bytes[1] < 0x80) return 2 + bytes[1]; + const count = bytes[1] & 0x7f; + if (count === 0 || count > 4 || bytes.length < 2 + count) return null; + let length = 0; + for (let i = 0; i < count; i++) length = length * 256 + bytes[2 + i]; + return 2 + count + length; +} + export async function validateSignature( - signature: ExtractedSignature, + signature: ExtractedSignatureWithError, pdfBytes: Uint8Array, trustedCert?: forge.pki.Certificate ): Promise<SignatureValidationResult> { @@ -99,6 +336,13 @@ export async function validateSignature( contactInfo: signature.contactInfo, }; + if (signature.extractionError) { + result.errorMessage = signature.extractionError; + result.cryptoVerified = false; + result.cryptoVerificationStatus = 'failed'; + return result; + } + try { const binaryString = String.fromCharCode.apply( null, @@ -113,6 +357,8 @@ export async function validateSignature( authenticatedAttributes?: forge.asn1.Asn1[]; signature?: string; signatureAlgorithm?: forge.asn1.Asn1[]; + issuer?: forge.asn1.Asn1; + serial?: string; }; }; @@ -121,7 +367,12 @@ export async function validateSignature( return result; } - const signerCert = p7.certificates[0] as forge.pki.Certificate; + const certificates = p7.certificates as forge.pki.Certificate[]; + const signerCert = findSignerCertificate( + certificates, + p7.rawCapture?.issuer, + p7.rawCapture?.serial + ); const subjectCN = signerCert.subject.getField('CN'); const subjectO = signerCert.subject.getField('O'); @@ -148,27 +399,11 @@ export async function validateSignature( // Check trust against provided certificate (cryptographic, not name/serial match) if (trustedCert) { try { - const trustedPem = forge.pki.certificateToPem(trustedCert); - const isSameCert = - forge.pki.certificateToPem(signerCert) === trustedPem; - - let cryptoIssued = false; - const chain = [ + result.isTrusted = chainsToTrustedCert( signerCert, - ...(p7.certificates as forge.pki.Certificate[]), - ]; - for (const cert of chain) { - try { - if (trustedCert.verify(cert)) { - cryptoIssued = true; - break; - } - } catch { - continue; - } - } - - result.isTrusted = isSameCert || cryptoIssued; + certificates, + trustedCert + ); } catch { result.isTrusted = false; } @@ -267,6 +502,70 @@ export async function validateSignature( return result; } +function findSignerCertificate( + certificates: forge.pki.Certificate[], + issuer: forge.asn1.Asn1 | undefined, + serial: string | undefined +): forge.pki.Certificate { + if (issuer && serial) { + const serialHex = forge.util.createBuffer(serial).toHex(); + const issuerDer = forge.asn1.toDer(issuer).getBytes(); + const match = certificates.find( + (cert) => + cert.serialNumber === serialHex && + forge.asn1 + .toDer(forge.pki.distinguishedNameToAsn1(cert.issuer)) + .getBytes() === issuerDer + ); + if (match) return match; + } + return certificates[0]; +} + +function isIssuedBy( + issuer: forge.pki.Certificate, + child: forge.pki.Certificate +): boolean { + try { + return issuer.verify(child); + } catch { + return false; + } +} + +function isCertificateAuthority(cert: forge.pki.Certificate): boolean { + const basicConstraints = cert.getExtension('basicConstraints') as + | { cA?: boolean } + | undefined; + return basicConstraints?.cA === true; +} + +function chainsToTrustedCert( + signerCert: forge.pki.Certificate, + certificates: forge.pki.Certificate[], + trustedCert: forge.pki.Certificate +): boolean { + const trustedPem = forge.pki.certificateToPem(trustedCert); + const visited = new Set<string>(); + let current = signerCert; + + for (let depth = 0; depth < 10; depth++) { + const currentPem = forge.pki.certificateToPem(current); + if (currentPem === trustedPem || isIssuedBy(trustedCert, current)) { + return true; + } + if (visited.has(currentPem)) return false; + visited.add(currentPem); + + const parent = certificates.find( + (cert) => isCertificateAuthority(cert) && isIssuedBy(cert, current) + ); + if (!parent) return false; + current = parent; + } + return false; +} + export async function validatePdfSignatures( pdfBytes: Uint8Array, trustedCert?: forge.pki.Certificate @@ -286,13 +585,7 @@ function hexToBytes(hex: string): Uint8Array { for (let i = 0; i < hex.length; i += 2) { bytes[i / 2] = parseInt(hex.substr(i, 2), 16); } - - let actualLength = bytes.length; - while (actualLength > 0 && bytes[actualLength - 1] === 0) { - actualLength--; - } - - return bytes.slice(0, actualLength); + return bytes; } function getDigestAlgorithmName(oid: string): string { @@ -690,8 +983,9 @@ async function performCryptoVerification( if (!fields) { return { status: 'failed', reason: 'Could not parse signer info' }; } - if (byteRange.length !== 4) { - return { status: 'failed', reason: 'Malformed ByteRange' }; + const byteRangeError = getByteRangeError(byteRange, pdfBytes.length); + if (byteRangeError) { + return { status: 'failed', reason: byteRangeError }; } const md = createMd(fields.digestOid); @@ -703,17 +997,6 @@ async function performCryptoVerification( } const [start1, len1, start2, len2] = byteRange; - if ( - start1 < 0 || - len1 < 0 || - start2 < 0 || - len2 < 0 || - start1 + len1 > pdfBytes.length || - start2 + len2 > pdfBytes.length - ) { - return { status: 'failed', reason: 'ByteRange out of bounds' }; - } - const signedContent = new Uint8Array(len1 + len2); signedContent.set(pdfBytes.subarray(start1, start1 + len1), 0); signedContent.set(pdfBytes.subarray(start2, start2 + len2), len1); diff --git a/src/js/main.ts b/src/js/main.ts index 21565646..51583619 100644 --- a/src/js/main.ts +++ b/src/js/main.ts @@ -29,6 +29,7 @@ import { setStoredItem, removeStoredItem, } from './utils/safe-storage.js'; +import { sanitizeTranslationHtml } from './utils/translation-html.js'; declare const __BRAND_NAME__: string; const init = async () => { @@ -48,9 +49,9 @@ const init = async () => { main.innerHTML = ` <div class="flex flex-col items-center justify-center min-h-[60vh] text-center px-4"> <i class="ph ph-prohibit text-6xl text-gray-500 mb-4"></i> - <h1 class="text-2xl font-bold text-white mb-2">${heading}</h1> - <p class="text-gray-400 mb-6">${message}</p> - <a href="${import.meta.env.BASE_URL}" class="px-6 py-2 bg-indigo-600 hover:bg-indigo-700 text-white rounded-lg transition">${backHome}</a> + <h1 class="text-2xl font-bold text-white mb-2">${sanitizeTranslationHtml(heading)}</h1> + <p class="text-gray-400 mb-6">${sanitizeTranslationHtml(message)}</p> + <a href="${import.meta.env.BASE_URL}" class="px-6 py-2 bg-indigo-600 hover:bg-indigo-700 text-white rounded-lg transition">${sanitizeTranslationHtml(backHome)}</a> </div> `; return; @@ -314,7 +315,11 @@ const init = async () => { filteredCategories.forEach((category) => { const categoryGroup = document.createElement('div'); - categoryGroup.className = 'category-group col-span-full'; + const categorySlug = category.name + .toLowerCase() + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-|-$/g, ''); + categoryGroup.className = `category-group col-span-full is-cat-${categorySlug}`; const header = document.createElement('button'); header.className = 'category-header'; @@ -539,6 +544,49 @@ const init = async () => { }); } + const faqDetails = + document.querySelectorAll<HTMLDetailsElement>('details.faq-d'); + if ( + faqDetails.length > 0 && + !window.matchMedia('(prefers-reduced-motion: reduce)').matches + ) { + faqDetails.forEach((detail) => { + const summary = detail.querySelector('summary'); + const body = detail.querySelector<HTMLElement>('.faq-d-a'); + if (!summary || !body) return; + + let animation: Animation | null = null; + + summary.addEventListener('click', (event) => { + event.preventDefault(); + animation?.cancel(); + + const wasOpen = detail.open; + if (!wasOpen) detail.open = true; + + const fullHeight = `${body.scrollHeight}px`; + const fullPadding = window.getComputedStyle(body).paddingBottom; + const collapsed = { height: '0px', paddingBottom: '0px', opacity: 0 }; + const expanded = { + height: fullHeight, + paddingBottom: fullPadding, + opacity: 1, + }; + + body.style.overflow = 'hidden'; + animation = body.animate( + wasOpen ? [expanded, collapsed] : [collapsed, expanded], + { duration: 280, easing: 'cubic-bezier(0.2, 0.7, 0.2, 1)' } + ); + animation.onfinish = () => { + if (wasOpen) detail.open = false; + body.style.overflow = ''; + animation = null; + }; + }); + }); + } + createIcons({ icons }); console.log('Please share our tool and share the love!'); @@ -851,7 +899,7 @@ const init = async () => { } dom.warningTitle.textContent = title; - dom.warningMessage.innerHTML = message; + dom.warningMessage.innerHTML = sanitizeTranslationHtml(message); dom.warningModal.classList.remove('hidden'); dom.warningModal.classList.add('flex'); diff --git a/src/js/mobileMenu.ts b/src/js/mobileMenu.ts index 60143814..307b4f05 100644 --- a/src/js/mobileMenu.ts +++ b/src/js/mobileMenu.ts @@ -50,4 +50,50 @@ document.addEventListener('DOMContentLoaded', () => { } }); } + + const mobileProductsButton = document.getElementById( + 'mobile-products-button' + ); + const mobileProductsMenu = document.getElementById('mobile-products-menu'); + + if (mobileProductsButton && mobileProductsMenu) { + mobileProductsButton.addEventListener('click', (event) => { + event.stopPropagation(); + const isOpen = + mobileProductsButton.getAttribute('aria-expanded') === 'true'; + mobileProductsMenu.hidden = isOpen; + mobileProductsButton.setAttribute('aria-expanded', (!isOpen).toString()); + }); + } + + const productsButton = document.getElementById('products-menu-button'); + const productsMenu = document.getElementById('products-menu'); + + if (productsButton && productsMenu) { + const closeProducts = () => { + productsMenu.hidden = true; + productsButton.setAttribute('aria-expanded', 'false'); + }; + + productsButton.addEventListener('click', (event) => { + event.stopPropagation(); + const isOpen = productsButton.getAttribute('aria-expanded') === 'true'; + productsMenu.hidden = isOpen; + productsButton.setAttribute('aria-expanded', (!isOpen).toString()); + }); + + document.addEventListener('click', (event) => { + const target = event.target as Node; + if (!productsMenu.contains(target) && !productsButton.contains(target)) { + closeProducts(); + } + }); + + document.addEventListener('keydown', (event) => { + if (event.key === 'Escape' && !productsMenu.hidden) { + closeProducts(); + productsButton.focus(); + } + }); + } }); diff --git a/src/js/types/compress-pdf-type.ts b/src/js/types/compress-pdf-type.ts new file mode 100644 index 00000000..ba2b2fc7 --- /dev/null +++ b/src/js/types/compress-pdf-type.ts @@ -0,0 +1,58 @@ +import type { + HyperCompressOptions, + HyperErrorCode, + HyperWasmOptions, + HyperWasmResult, +} from 'hyper-compress-wasm'; + +export type HyperCompressRequest = + | { + id: number; + type: 'compress'; + bytes: ArrayBuffer; + options: HyperWasmOptions; + } + | { id: number; type: 'verify'; bytes: ArrayBuffer; password: string }; + +export type HyperCompressResponse = + | { id: number; type: 'result'; result: HyperWasmResult } + | { id: number; type: 'verified'; valid: boolean } + | { id: number; type: 'error'; code: HyperErrorCode; message: string }; + +export type CompressLockStatus = + | 'checking' + | 'open' + | 'locked' + | 'wrong' + | 'unlocked'; + +export interface CompressFileLock { + status: CompressLockStatus; + password: string; + attempt: number; +} + +export interface CompressPasswordField { + root: HTMLElement; + input: HTMLInputElement; + status: HTMLElement; + lockIcon: HTMLElement; + unlockIcon: HTMLElement; +} + +interface HyperOptionFieldBase { + key: keyof HyperCompressOptions; + label: string; + help?: string; +} + +export type HyperOptionField = + | (HyperOptionFieldBase & { type: 'bool' }) + | (HyperOptionFieldBase & { type: 'number'; min: number; max: number }) + | (HyperOptionFieldBase & { type: 'dpi' }); + +export interface HyperOptionSection { + title: string; + open?: boolean; + fields: HyperOptionField[]; +} diff --git a/src/js/types/edit-pdf-redaction-type.ts b/src/js/types/edit-pdf-redaction-type.ts new file mode 100644 index 00000000..1760491e --- /dev/null +++ b/src/js/types/edit-pdf-redaction-type.ts @@ -0,0 +1,36 @@ +export interface PendingRedactionItem { + id: string; + kind: 'text' | 'area'; + text?: string; +} + +export interface PendingRedactionChange { + documentId: string; + pending: Record<number, PendingRedactionItem[]>; +} + +export interface RedactionPluginEvent { + type: string; + documentId: string; + id?: string; +} + +export interface RedactionPluginLite { + getState: () => { pendingCount: number }; + commitAllPending: () => { toPromise: () => Promise<boolean> }; + onPendingChange: ( + listener: (event: PendingRedactionChange) => void + ) => () => void; + onRedactionEvent: ( + listener: (event: RedactionPluginEvent) => void + ) => () => void; +} + +export interface ActiveDocumentSource { + getActiveDocumentId: () => string | null; +} + +export interface AppliedRedactionTracker { + appliedTexts: (documentId: string) => (string | undefined)[]; + forget: (documentId: string) => void; +} diff --git a/src/js/types/index.ts b/src/js/types/index.ts index 836f92f6..1a9624af 100644 --- a/src/js/types/index.ts +++ b/src/js/types/index.ts @@ -66,3 +66,8 @@ export * from './ui-type.ts'; export * from './markdown-editor-type.ts'; export * from './sanitize-type.ts'; export * from './overlay-pdf-type.ts'; +export * from './workflow-control-spec-type.ts'; +export * from './edit-pdf-redaction-type.ts'; +export * from './validate-signature-extraction-type.ts'; +export * from './pdf-content-tokens-type.ts'; +export * from './compress-pdf-type.ts'; diff --git a/src/js/types/pdf-content-tokens-type.ts b/src/js/types/pdf-content-tokens-type.ts new file mode 100644 index 00000000..817a40b2 --- /dev/null +++ b/src/js/types/pdf-content-tokens-type.ts @@ -0,0 +1,13 @@ +export interface ContentToken { + kind: 'name' | 'dict' | 'operand' | 'operator'; + value: string; + start: number; + end: number; +} + +export interface ContentOperation { + operator: string; + operands: ContentToken[]; + start: number; + end: number; +} diff --git a/src/js/types/validate-signature-extraction-type.ts b/src/js/types/validate-signature-extraction-type.ts new file mode 100644 index 00000000..34e3302e --- /dev/null +++ b/src/js/types/validate-signature-extraction-type.ts @@ -0,0 +1,10 @@ +import { ExtractedSignature } from './validate-signature-type.ts'; + +export interface PdfValueSpan { + start: number; + end: number; +} + +export interface ExtractedSignatureWithError extends ExtractedSignature { + extractionError?: string; +} diff --git a/src/js/types/workflow-control-spec-type.ts b/src/js/types/workflow-control-spec-type.ts new file mode 100644 index 00000000..9590ca39 --- /dev/null +++ b/src/js/types/workflow-control-spec-type.ts @@ -0,0 +1,12 @@ +export interface WorkflowControlOption { + label: string; + value: string; +} + +export type WorkflowControlSpec = + | { kind: 'select'; options: WorkflowControlOption[] } + | { kind: 'multiselect'; options: WorkflowControlOption[]; separator: string } + | { kind: 'number'; min: number; max: number; step?: number } + | { kind: 'boolean' } + | { kind: 'color' } + | { kind: 'password' }; diff --git a/src/js/utils/compress.ts b/src/js/utils/compress.ts index d933406f..4f51414f 100644 --- a/src/js/utils/compress.ts +++ b/src/js/utils/compress.ts @@ -1,161 +1,188 @@ -import { PDFDocument } from 'pdf-lib'; -import { getPDFDocument } from './helpers.js'; -import { loadPyMuPDF } from './pymupdf-loader.js'; +import { + HYPER_PRESETS, + HyperError, + normalizeHyperOptions, +} from 'hyper-compress-wasm'; +import type { + CompressLevel, + HyperCompressOptions, + HyperWasmOptions, + HyperWasmResult, +} from 'hyper-compress-wasm'; +import type { HyperCompressRequest, HyperCompressResponse } from '@/types'; -export const CONDENSE_PRESETS = { - light: { - images: { quality: 90, dpiTarget: 150, dpiThreshold: 200 }, - scrub: { metadata: false, thumbnails: true }, - subsetFonts: true, - }, - balanced: { - images: { quality: 75, dpiTarget: 96, dpiThreshold: 150 }, - scrub: { metadata: true, thumbnails: true }, - subsetFonts: true, - }, - aggressive: { - images: { quality: 50, dpiTarget: 72, dpiThreshold: 100 }, - scrub: { metadata: true, thumbnails: true, xmlMetadata: true }, - subsetFonts: true, - }, - extreme: { - images: { quality: 30, dpiTarget: 60, dpiThreshold: 96 }, - scrub: { metadata: true, thumbnails: true, xmlMetadata: true }, - subsetFonts: true, - }, +export const COMPRESS_LEVELS: readonly CompressLevel[] = [ + 'low', + 'medium', + 'high', + 'lossless', +]; + +const LEGACY_LEVELS: Record<string, CompressLevel> = { + light: 'low', + balanced: 'medium', + aggressive: 'high', + extreme: 'high', }; -export const PHOTON_PRESETS = { - light: { scale: 2.0, quality: 0.85 }, - balanced: { scale: 1.5, quality: 0.65 }, - aggressive: { scale: 1.2, quality: 0.45 }, - extreme: { scale: 1.0, quality: 0.25 }, -}; +const PRESET_INDEPENDENT_OPTIONS: ReadonlySet<keyof HyperCompressOptions> = + new Set([ + 'grayscale', + 'preserveConformance', + 'brotli', + 'rasterizeText', + 'removeSignatures', + ]); -export interface CondenseCustomSettings { - imageQuality?: number; - dpiTarget?: number; - dpiThreshold?: number; - removeMetadata?: boolean; - subsetFonts?: boolean; - convertToGrayscale?: boolean; - removeThumbnails?: boolean; +export function resolveCompressLevel(value: string): CompressLevel { + return ( + COMPRESS_LEVELS.find((level) => level === value) ?? + (Object.hasOwn(LEGACY_LEVELS, value) ? LEGACY_LEVELS[value] : 'medium') + ); } -export async function performCondenseCompression( - fileBlob: Blob, - level: string, - customSettings?: CondenseCustomSettings -) { - const pymupdf = await loadPyMuPDF(); +export function applyHyperPreset( + level: CompressLevel, + current: HyperCompressOptions +): HyperCompressOptions { + return normalizeHyperOptions({ + ...HYPER_PRESETS[level], + grayscale: current.grayscale, + preserveConformance: current.preserveConformance, + brotli: current.brotli, + rasterizeText: current.rasterizeText, + removeSignatures: current.removeSignatures, + }); +} - const preset = - CONDENSE_PRESETS[level as keyof typeof CONDENSE_PRESETS] || - CONDENSE_PRESETS.balanced; +export function matchHyperPreset( + options: HyperCompressOptions +): CompressLevel | null { + return ( + COMPRESS_LEVELS.find((level) => { + const preset = normalizeHyperOptions(HYPER_PRESETS[level]); + return (Object.keys(preset) as (keyof HyperCompressOptions)[]).every( + (key) => + PRESET_INDEPENDENT_OPTIONS.has(key) || preset[key] === options[key] + ); + }) ?? null + ); +} - const dpiTarget = customSettings?.dpiTarget ?? preset.images.dpiTarget; - const userThreshold = - customSettings?.dpiThreshold ?? preset.images.dpiThreshold; - const dpiThreshold = Math.max(userThreshold, dpiTarget + 10); +export function hasPdfHeader(head: ArrayBuffer): boolean { + const bytes = new Uint8Array(head, 0, Math.min(head.byteLength, 1024)); + return new TextDecoder('latin1').decode(bytes).includes('%PDF-'); +} - const options = { - images: { - enabled: true, - quality: customSettings?.imageQuality ?? preset.images.quality, - dpiTarget, - dpiThreshold, - convertToGray: customSettings?.convertToGrayscale ?? false, - }, - scrub: { - metadata: customSettings?.removeMetadata ?? preset.scrub.metadata, - thumbnails: customSettings?.removeThumbnails ?? preset.scrub.thumbnails, - xmlMetadata: - ('xmlMetadata' in preset.scrub - ? (preset.scrub as { xmlMetadata?: boolean }).xmlMetadata - : undefined) ?? false, - }, - subsetFonts: customSettings?.subsetFonts ?? preset.subsetFonts, - save: { - garbage: 4 as const, - deflate: true, - clean: true, - useObjstms: true, - }, - }; +export function targetSizeToBytes( + value: number, + unit: string +): number | undefined { + if (!Number.isFinite(value) || value <= 0) return undefined; + const bytes = Math.floor(value * (unit === 'kb' ? 1024 : 1024 * 1024)); + return bytes >= 1 ? bytes : undefined; +} - try { - const result = await pymupdf.compressPdf(fileBlob, options); - return result; - } catch { - const fallbackOptions = { - ...options, - images: { - ...options.images, - enabled: false, - }, - }; - - try { - const result = await pymupdf.compressPdf(fileBlob, fallbackOptions); - return { ...result, usedFallback: true }; - } catch (fallbackError: unknown) { - const msg = - fallbackError instanceof Error - ? fallbackError.message - : String(fallbackError); - throw new Error(`PDF compression failed: ${msg}`, { - cause: fallbackError, - }); - } +let hyperWorker: Worker | null = null; +let nextHyperJobId = 0; +const hyperJobs = new Map< + number, + { + resolve: (message: HyperCompressResponse) => void; + reject: (err: Error) => void; } +>(); + +function discardHyperWorker(worker: Worker, error?: Error): void { + worker.terminate(); + if (hyperWorker === worker) hyperWorker = null; + if (!error) return; + for (const job of hyperJobs.values()) job.reject(error); + hyperJobs.clear(); } -export async function performPhotonCompression( - arrayBuffer: ArrayBuffer, - level: string -): Promise<Uint8Array> { - const pdfJsDoc = await getPDFDocument({ data: arrayBuffer }).promise; - const newPdfDoc = await PDFDocument.create(); - const settings = - PHOTON_PRESETS[level as keyof typeof PHOTON_PRESETS] || - PHOTON_PRESETS.balanced; - - for (let i = 1; i <= pdfJsDoc.numPages; i++) { - const page = await pdfJsDoc.getPage(i); - const viewport = page.getViewport({ scale: settings.scale }); - const canvas = document.createElement('canvas'); - const context = canvas.getContext('2d'); - if (!context) throw new Error('Failed to create canvas context'); - canvas.height = viewport.height; - canvas.width = viewport.width; - - await page.render({ canvasContext: context, viewport, canvas: canvas }) - .promise; - - const jpegBlob = await new Promise<Blob>((resolve, reject) => - canvas.toBlob( - (blob) => { - if (blob) resolve(blob); - else reject(new Error('Failed to create JPEG blob')); - }, - 'image/jpeg', - settings.quality +function getHyperWorker(): Worker { + if (hyperWorker) return hyperWorker; + const worker = new Worker( + new URL('./hyper-compress.worker.ts', import.meta.url), + { type: 'module' } + ); + worker.onmessage = (e: MessageEvent<HyperCompressResponse>) => { + const message = e.data; + const job = hyperJobs.get(message.id); + if (!job) return; + hyperJobs.delete(message.id); + if (message.type !== 'error') { + job.resolve(message); + return; + } + job.reject(new HyperError(message.code, message.message)); + if (message.code === 'engine_error' && hyperJobs.size === 0) { + discardHyperWorker(worker); + } + }; + worker.onerror = (e: ErrorEvent) => { + e.preventDefault(); + discardHyperWorker( + worker, + new HyperError( + 'engine_error', + e.message || 'The compression engine stopped unexpectedly.' ) ); + }; + worker.onmessageerror = () => { + discardHyperWorker( + worker, + new HyperError( + 'engine_error', + 'The compression engine returned an unreadable result.' + ) + ); + }; + hyperWorker = worker; + return worker; +} - // Release canvas memory - canvas.width = 0; - canvas.height = 0; +function postToHyper( + build: (id: number) => HyperCompressRequest, + transfer: ArrayBuffer +): Promise<HyperCompressResponse> { + const worker = getHyperWorker(); + const id = ++nextHyperJobId; + return new Promise((resolve, reject) => { + hyperJobs.set(id, { resolve, reject }); + worker.postMessage(build(id), [transfer]); + }); +} - const jpegBytes = await jpegBlob.arrayBuffer(); - const jpegImage = await newPdfDoc.embedJpg(jpegBytes); - const newPage = newPdfDoc.addPage([viewport.width, viewport.height]); - newPage.drawImage(jpegImage, { - x: 0, - y: 0, - width: viewport.width, - height: viewport.height, - }); +export async function compressWithHyper( + pdf: ArrayBuffer, + options: HyperWasmOptions +): Promise<HyperWasmResult> { + if (!hasPdfHeader(pdf)) { + throw new HyperError('engine_error', 'This file is not a valid PDF.'); } - return await newPdfDoc.save(); + const message = await postToHyper( + (id) => ({ id, type: 'compress', bytes: pdf, options }), + pdf + ); + if (message.type !== 'result') { + throw new HyperError('engine_error', 'Unexpected reply from the engine.'); + } + return message.result; +} + +export async function verifyHyperPassword( + pdf: ArrayBuffer, + password: string +): Promise<boolean> { + const message = await postToHyper( + (id) => ({ id, type: 'verify', bytes: pdf, password }), + pdf + ); + if (message.type !== 'verified') { + throw new HyperError('engine_error', 'Unexpected reply from the engine.'); + } + return message.valid; } diff --git a/src/js/utils/disabled-tools.ts b/src/js/utils/disabled-tools.ts index 5f35c622..cf759288 100644 --- a/src/js/utils/disabled-tools.ts +++ b/src/js/utils/disabled-tools.ts @@ -1,6 +1,18 @@ import type { AppConfig } from '@/types'; -const disabledToolsSet = new Set<string>(__DISABLED_TOOLS__); +const RENAMED_TOOL_IDS: Record<string, string> = { + 'decrypt-pdf': 'unlock-pdf', + 'encrypt-pdf': 'protect-pdf', + 'pdf-to-docx': 'pdf-to-word', +}; + +function normalizeToolId(toolId: string): string { + return RENAMED_TOOL_IDS[toolId] ?? toolId; +} + +const disabledToolsSet = new Set<string>( + __DISABLED_TOOLS__.map(normalizeToolId) +); let runtimeConfigLoaded = false; let editorDisabledCategories: string[] = []; @@ -23,7 +35,7 @@ export async function loadRuntimeConfig(): Promise<void> { if (Array.isArray(config.disabledTools)) { for (const toolId of config.disabledTools) { if (typeof toolId === 'string') { - disabledToolsSet.add(toolId); + disabledToolsSet.add(normalizeToolId(toolId)); } } } diff --git a/src/js/utils/flatten-annotations.ts b/src/js/utils/flatten-annotations.ts index fa8bd931..c4990f72 100644 --- a/src/js/utils/flatten-annotations.ts +++ b/src/js/utils/flatten-annotations.ts @@ -7,8 +7,65 @@ import { PDFNumber, PDFOperator, PDFOperatorNames, + PDFObject, + PDFTextField, } from 'pdf-lib'; +const HIDDEN_ANNOTATION_FLAGS = 0x02 | 0x20; + +function isHiddenAnnotation(annot: PDFDict): boolean { + const flags = annot.lookup(PDFName.of('F')); + return ( + flags instanceof PDFNumber && + (flags.asNumber() & HIDDEN_ANNOTATION_FLAGS) !== 0 + ); +} + +function detachAnnotations(pdfDoc: PDFDocument, refs: Set<PDFObject>) { + for (const page of pdfDoc.getPages()) { + const annots = page.node.Annots(); + if (!annots) continue; + for (let i = annots.size() - 1; i >= 0; i--) { + if (refs.has(annots.get(i))) annots.remove(i); + } + } + for (const ref of refs) { + if (ref instanceof PDFRef) pdfDoc.context.delete(ref); + } +} + +function removeHiddenFormFields(pdfDoc: PDFDocument) { + const form = pdfDoc.getForm(); + const removedRefs = new Set<PDFObject>(); + for (const field of form.getFields()) { + const removeAll = field instanceof PDFTextField && field.isPassword(); + const kids = field.acroField.Kids(); + if (kids) { + for (let i = kids.size() - 1; i >= 0; i--) { + const widget = kids.lookup(i); + if ( + removeAll || + (widget instanceof PDFDict && isHiddenAnnotation(widget)) + ) { + removedRefs.add(kids.get(i)); + kids.remove(i); + } + } + if (kids.size() > 0) continue; + } else if (!removeAll && !isHiddenAnnotation(field.acroField.dict)) { + continue; + } + form.acroForm.removeField(field.acroField); + removedRefs.add(field.ref); + } + if (removedRefs.size > 0) detachAnnotations(pdfDoc, removedRefs); +} + +export function flattenFormsInDoc(pdfDoc: PDFDocument): void { + removeHiddenFormFields(pdfDoc); + pdfDoc.getForm().flatten(); +} + function extractNumbers(arr: PDFArray, count: number): number[] | null { if (arr.size() < count) return null; const result: number[] = []; @@ -33,6 +90,53 @@ function resolveStreamDict(obj: unknown): PDFDict | null { return null; } +const IDENTITY_MATRIX = [1, 0, 0, 1, 0, 0]; + +function readFormMatrix(dict: PDFDict | null): number[] { + if (!dict) return IDENTITY_MATRIX; + const matrixObj = dict.lookup(PDFName.of('Matrix')); + if (!(matrixObj instanceof PDFArray) || matrixObj.size() !== 6) { + return IDENTITY_MATRIX; + } + return extractNumbers(matrixObj, 6) ?? IDENTITY_MATRIX; +} + +// ISO 32000-1 §12.5.5: map the matrix-transformed BBox onto Rect. +// Do still applies the form Matrix, so this cm must not include it again. +function alignAppearanceToRect( + bbox: number[], + matrix: number[], + x1: number, + y1: number, + x2: number, + y2: number +): [number, number, number, number] | null { + const [a, b, c, d, e, f] = matrix; + let minX = Infinity; + let minY = Infinity; + let maxX = -Infinity; + let maxY = -Infinity; + + for (const x of [bbox[0], bbox[2]]) { + for (const y of [bbox[1], bbox[3]]) { + const px = a * x + c * y + e; + const py = b * x + d * y + f; + if (px < minX) minX = px; + if (py < minY) minY = py; + if (px > maxX) maxX = px; + if (py > maxY) maxY = py; + } + } + + const bw = maxX - minX; + const bh = maxY - minY; + if (bw < 0.001 || bh < 0.001) return null; + + const sx = (x2 - x1) / bw; + const sy = (y2 - y1) / bh; + return [sx, sy, x1 - minX * sx, y1 - minY * sy]; +} + export function flattenAnnotations(pdfDoc: PDFDocument): void { const pages = pdfDoc.getPages(); @@ -68,9 +172,7 @@ export function flattenAnnotations(pdfDoc: PDFDocument): void { continue; } - const flagsObj = annot.get(PDFName.of('F')); - const flags = flagsObj instanceof PDFNumber ? flagsObj.asNumber() : 0; - if (flags & 0x02) { + if (isHiddenAnnotation(annot)) { hasChanges = true; continue; } @@ -132,6 +234,20 @@ export function flattenAnnotations(pdfDoc: PDFDocument): void { } } + const placement = alignAppearanceToRect( + bbox, + readFormMatrix(streamDict), + x1, + y1, + x2, + y2 + ); + if (!placement) { + hasChanges = true; + continue; + } + const [sx, sy, tx, ty] = placement; + let appRef: PDFRef; if (normalAppRef instanceof PDFRef) { appRef = normalAppRef; @@ -140,13 +256,6 @@ export function flattenAnnotations(pdfDoc: PDFDocument): void { } const xObjKey = pageNode.newXObject('FlatAnnot', appRef); - const bw = bbox[2] - bbox[0]; - const bh = bbox[3] - bbox[1]; - const sx = Math.abs(bw) > 0.001 ? (x2 - x1) / bw : 1; - const sy = Math.abs(bh) > 0.001 ? (y2 - y1) / bh : 1; - const tx = x1 - bbox[0] * sx; - const ty = y1 - bbox[1] * sy; - page.pushOperators( PDFOperator.of(PDFOperatorNames.PushGraphicsState), PDFOperator.of(PDFOperatorNames.ConcatTransformationMatrix, [ diff --git a/src/js/utils/font-loader.ts b/src/js/utils/font-loader.ts index b27c27e2..62e8b007 100644 --- a/src/js/utils/font-loader.ts +++ b/src/js/utils/font-loader.ts @@ -3,6 +3,7 @@ import { getFontUrlForFamily, languageToFontFamily, } from '../config/font-mappings.js'; +import { needsFontEmbedding } from './freetext-script.js'; const fontCache: Map<string, ArrayBuffer> = new Map(); @@ -134,6 +135,56 @@ export async function getFontForLanguage(lang: string): Promise<ArrayBuffer> { } } +const fallbackFontLoads = new Map<string, Promise<boolean>>(); + +const fallbackFontFamily = (lang: string): string => + languageToFontFamily[lang] || 'Noto Sans'; + +export function fallbackFontLanguages(text: string): Set<string> { + const langs = new Set<string>(); + for (const ch of text) { + if (needsFontEmbedding(ch)) langs.add(getLanguageForChar(ch)); + } + return langs; +} + +export function hasFallbackFonts( + loaded: Map<string, Uint8Array>, + langs: Iterable<string> +): boolean { + return [...langs].every((lang) => loaded.has(fallbackFontFamily(lang))); +} + +export async function loadFallbackFonts( + into: Map<string, Uint8Array>, + langs: Iterable<string> +): Promise<boolean> { + const results = await Promise.all( + [...langs].map((lang) => { + const family = fallbackFontFamily(lang); + let load = fallbackFontLoads.get(family); + if (!load) { + load = getFontForLanguage(lang) + .then((buffer) => { + if (!fontCache.has(family)) { + throw new Error('Only a substitute font was available'); + } + into.set(family, new Uint8Array(buffer)); + return true; + }) + .catch((error: unknown) => { + fallbackFontLoads.delete(family); + console.warn(`Fallback font unavailable for ${family}`, error); + return false; + }); + fallbackFontLoads.set(family, load); + } + return load; + }) + ); + return results.every(Boolean); +} + export function detectScripts(text: string): string[] { const scripts = new Set<string>(); diff --git a/src/js/utils/freetext-flatten.ts b/src/js/utils/freetext-flatten.ts index a382f9d4..704e9c83 100644 --- a/src/js/utils/freetext-flatten.ts +++ b/src/js/utils/freetext-flatten.ts @@ -1,5 +1,6 @@ import type { FreeTextSystemFontAnnotation } from '@/types'; import { needsFontEmbedding } from './freetext-script.js'; +import { fallbackFontLanguages, loadFallbackFonts } from './font-loader.js'; export const FPDF_ANNOT_FREETEXT = 3; @@ -153,7 +154,7 @@ function buildRuns( return [ { text: contents, - family: 'Helvetica', + family: needsFontEmbedding(contents) ? 'Noto Sans' : 'Helvetica', size, rgba: hexToRgba(style?.fontColor ?? '#000000'), bold: false, @@ -196,6 +197,7 @@ export async function flattenFreeTextToPageText( PdfEngine: { create: () => Promise<EngineLike>; localFonts: Map<string, Uint8Array>; + fallbackFonts: Map<string, Uint8Array>; }; scriptFallbackFamilies: (cps: number[]) => string[]; }; @@ -245,6 +247,10 @@ export async function flattenFreeTextToPageText( return { bytes, flattened: 0 }; } await primeScriptFonts(mod, found); + await loadFallbackFonts( + mod.PdfEngine.fallbackFonts, + fallbackFontLanguages(found.join('\n')) + ); for (const pageIndex of [...pages].sort((x, y) => x - y)) { if (pageIndex < 0 || pageIndex >= eng.pageCount) continue; diff --git a/src/js/utils/ghostscript-loader.ts b/src/js/utils/ghostscript-loader.ts index 8bd44353..bf1c6b83 100644 --- a/src/js/utils/ghostscript-loader.ts +++ b/src/js/utils/ghostscript-loader.ts @@ -163,7 +163,7 @@ export async function convertToPdfA( } const args = [ - '-dNOSAFER', + '-dSAFER', '-dBATCH', '-dNOPAUSE', '-sDEVICE=pdfwrite', @@ -418,7 +418,7 @@ export async function convertFontsToOutlines( onProgress?.('Converting fonts to outlines...'); const args = [ - '-dNOSAFER', + '-dSAFER', '-dBATCH', '-dNOPAUSE', '-sDEVICE=pdfwrite', @@ -487,3 +487,81 @@ export async function convertFileToOutlines( copy.set(result); return new Blob([copy], { type: 'application/pdf' }); } + +const CMYK_COLOR_SPACES = /\/DeviceCMYK|\/DeviceN\b|\/Separation\b/; +const FOUR_CHANNELS = /\/N\s+4\b/g; +const COUNT_DICTIONARIES = /\/ObjStm|\/Linearized/; + +const RGB_ARGS = [ + '-dSAFER', + '-dBATCH', + '-dNOPAUSE', + '-dQUIET', + '-sDEVICE=pdfwrite', + '-sColorConversionStrategy=sRGB', + '-sColorConversionStrategyForImages=sRGB', + '-dConvertCMYKImagesToRGB=true', + '-dProcessColorModel=/DeviceRGB', + '-dAutoFilterColorImages=true', + '-dAutoFilterGrayImages=true', + '-dColorImageFilter=/DCTEncode', + '-dGrayImageFilter=/DCTEncode', + '-dCompatibilityLevel=1.4', +]; + +export function pdfUsesCmyk(pdfData: Uint8Array): boolean { + const text = new TextDecoder('latin1').decode(pdfData); + if (CMYK_COLOR_SPACES.test(text)) return true; + for (const match of text.matchAll(FOUR_CHANNELS)) { + const start = text.lastIndexOf('<<', match.index); + const end = text.indexOf('>>', match.index); + if (!COUNT_DICTIONARIES.test(text.slice(start, end))) return true; + } + return false; +} + +export async function convertPdfToRgb( + pdfData: Uint8Array +): Promise<Uint8Array> { + const gs = cachedGsModule ?? (cachedGsModule = await loadGsModule()); + const inputPath = '/tmp/rgb-input.pdf'; + const outputPath = '/tmp/rgb-output.pdf'; + gs.FS.writeFile(inputPath, pdfData); + try { + const exitCode = gs.callMain([ + ...RGB_ARGS, + `-sOutputFile=${outputPath}`, + inputPath, + ]); + if (exitCode !== 0) { + throw new Error( + `Ghostscript RGB conversion failed with exit code ${exitCode}` + ); + } + return gs.FS.readFile(outputPath); + } finally { + for (const path of [inputPath, outputPath]) { + try { + gs.FS.unlink(path); + } catch { + continue; + } + } + } +} + +export async function normalizeCmykForDocx(pdf: Blob): Promise<Blob> { + if (!isWasmAvailable('ghostscript')) return pdf; + const pdfData = new Uint8Array(await pdf.arrayBuffer()); + if (!pdfUsesCmyk(pdfData)) return pdf; + try { + const rgb = await convertPdfToRgb(pdfData); + return new Blob([new Uint8Array(rgb)], { type: 'application/pdf' }); + } catch (e) { + console.warn( + '[Ghostscript] RGB conversion failed, converting the original PDF:', + e + ); + return pdf; + } +} diff --git a/src/js/utils/hyper-compress.worker.ts b/src/js/utils/hyper-compress.worker.ts new file mode 100644 index 00000000..51e9dc8f --- /dev/null +++ b/src/js/utils/hyper-compress.worker.ts @@ -0,0 +1,59 @@ +import { compress, verifyPassword, HyperError } from 'hyper-compress-wasm'; +import type { HyperCompressRequest, HyperCompressResponse } from '@/types'; + +const ENCRYPT_KEY = new TextEncoder().encode('/Encrypt'); + +function hasEncryptKey(bytes: Uint8Array): boolean { + for ( + let i = bytes.indexOf(ENCRYPT_KEY[0]); + i !== -1; + i = bytes.indexOf(ENCRYPT_KEY[0], i + 1) + ) { + if (ENCRYPT_KEY.every((byte, j) => bytes[i + j] === byte)) return true; + } + return false; +} + +async function opensWith( + input: Uint8Array, + password: string +): Promise<boolean> { + return !hasEncryptKey(input) || verifyPassword(input, password); +} + +self.onmessage = async (e: MessageEvent<HyperCompressRequest>) => { + const request = e.data; + const { id } = request; + try { + const input = new Uint8Array(request.bytes); + if (request.type === 'verify') { + const response: HyperCompressResponse = { + id, + type: 'verified', + valid: await opensWith(input, request.password), + }; + self.postMessage(response); + return; + } + const { options } = request; + const result = await compress(input, options); + if ( + !result.signed && + result.compressedSize === result.originalSize && + !options.password && + !(await opensWith(input, '')) + ) { + throw new HyperError('decrypt_failed', 'wrong or missing password'); + } + const response: HyperCompressResponse = { id, type: 'result', result }; + self.postMessage(response, { transfer: [result.data.buffer] }); + } catch (err) { + const response: HyperCompressResponse = { + id, + type: 'error', + code: err instanceof HyperError ? err.code : 'engine_error', + message: err instanceof Error ? err.message : String(err), + }; + self.postMessage(response); + } +}; diff --git a/src/js/utils/load-pdf-document.ts b/src/js/utils/load-pdf-document.ts index b287651c..ca421b70 100644 --- a/src/js/utils/load-pdf-document.ts +++ b/src/js/utils/load-pdf-document.ts @@ -4,13 +4,16 @@ import { initializeQpdf } from './helpers.js'; type LoadOptions = Parameters<typeof PDFDocument.load>[1]; type PDFDocumentInstance = Awaited<ReturnType<typeof PDFDocument.load>>; -async function repairPdfBytes(pdf: Uint8Array): Promise<Uint8Array | null> { +async function runQpdf( + pdf: Uint8Array, + args: string[] +): Promise<Uint8Array | null> { try { const qpdf = await initializeQpdf(); qpdf.FS.writeFile('/input.pdf', pdf); try { - qpdf.callMain(['/input.pdf', '--decrypt', '/output.pdf']); + qpdf.callMain(['/input.pdf', ...args, '/output.pdf']); } catch (e) { console.warn('[loadPdfDocument] qpdf repair warning:', e); } @@ -40,6 +43,23 @@ async function repairPdfBytes(pdf: Uint8Array): Promise<Uint8Array | null> { } } +function repairPdfBytes(pdf: Uint8Array): Promise<Uint8Array | null> { + return runQpdf(pdf, ['--decrypt']); +} + +export async function removeUnreferencedObjects( + pdf: Uint8Array | ArrayBuffer +): Promise<Uint8Array> { + const cleaned = await runQpdf( + pdf instanceof Uint8Array ? pdf : new Uint8Array(pdf), + [] + ); + if (!cleaned) { + throw new Error('Could not remove leftover data from the PDF.'); + } + return cleaned; +} + export async function loadPdfDocument( pdf: Uint8Array | ArrayBuffer, options?: LoadOptions diff --git a/src/js/utils/markdown-editor.ts b/src/js/utils/markdown-editor.ts index a142d16f..5f603097 100644 --- a/src/js/utils/markdown-editor.ts +++ b/src/js/utils/markdown-editor.ts @@ -674,7 +674,7 @@ export class MarkdownEditor { .use(emoji) // Emoji: :smile: -> 😄 .use(ins) // Inserted text: ++text++ -> <ins>text</ins> .use(mark) // Marked text: ==text== -> <mark>text</mark> - .use(taskLists, { enabled: true, label: true, labelAfter: true }) // Task lists: - [x] done + .use(taskLists, { enabled: true, label: false }) // Task lists: - [x] done .use(anchor, { permalink: false }) // Header anchors .use(tocDoneRight); // Table of contents: ${toc} } diff --git a/src/js/utils/pdf-content-tokens.ts b/src/js/utils/pdf-content-tokens.ts new file mode 100644 index 00000000..9b7712b6 --- /dev/null +++ b/src/js/utils/pdf-content-tokens.ts @@ -0,0 +1,299 @@ +import type { ContentOperation, ContentToken } from '@/types'; + +const charCode = (char: string) => char.charCodeAt(0); + +const BACKSLASH = charCode('\\'); +const OPEN_PAREN = charCode('('); +const CLOSE_PAREN = charCode(')'); +const LESS_THAN = charCode('<'); +const GREATER_THAN = charCode('>'); +const OPEN_BRACKET = charCode('['); +const CLOSE_BRACKET = charCode(']'); +const SLASH = charCode('/'); +const PERCENT = charCode('%'); +const LINE_FEED = charCode('\n'); +const CARRIAGE_RETURN = charCode('\r'); +const LETTER_E = charCode('E'); +const LETTER_I = charCode('I'); + +const WHITESPACE = new Set([0x00, 0x09, 0x0a, 0x0c, 0x0d, 0x20]); +const DELIMITERS = new Set([...'()<>[]{}/%'].map(charCode)); +const OPERAND_KEYWORDS = new Set(['true', 'false', 'null']); +const TEXT_CHECK_LENGTH = 16; + +const COMPONENTS_PER_COLOR_SPACE = new Map([ + ['G', 1], + ['DeviceGray', 1], + ['RGB', 3], + ['DeviceRGB', 3], + ['CMYK', 4], + ['DeviceCMYK', 4], + ['I', 1], + ['Indexed', 1], +]); + +function isRegularChar(byte: number): boolean { + return !WHITESPACE.has(byte) && !DELIMITERS.has(byte); +} + +function isHexDigit(byte: number): boolean { + return ( + (byte >= charCode('0') && byte <= charCode('9')) || + (byte >= charCode('A') && byte <= charCode('F')) || + (byte >= charCode('a') && byte <= charCode('f')) + ); +} + +function bytesToString(bytes: Uint8Array, start: number, end: number): string { + let text = ''; + for (let i = start; i < end; i++) text += String.fromCharCode(bytes[i]); + return text; +} + +function skipWhitespaceAndComments(bytes: Uint8Array, pos: number): number { + while (pos < bytes.length) { + if (WHITESPACE.has(bytes[pos])) { + pos++; + } else if (bytes[pos] === PERCENT) { + while ( + pos < bytes.length && + bytes[pos] !== LINE_FEED && + bytes[pos] !== CARRIAGE_RETURN + ) { + pos++; + } + } else { + break; + } + } + return pos; +} + +function findLiteralStringEnd(bytes: Uint8Array, start: number): number { + let depth = 0; + for (let i = start; i < bytes.length; i++) { + const byte = bytes[i]; + if (byte === BACKSLASH) { + i++; + } else if (byte === OPEN_PAREN) { + depth++; + } else if (byte === CLOSE_PAREN) { + depth--; + if (depth === 0) return i + 1; + } + } + throw new Error('Unterminated string in content stream'); +} + +function findHexStringEnd(bytes: Uint8Array, start: number): number { + for (let i = start + 1; i < bytes.length; i++) { + const byte = bytes[i]; + if (byte === GREATER_THAN) return i + 1; + if (!isHexDigit(byte) && !WHITESPACE.has(byte)) { + throw new Error('Invalid hex string in content stream'); + } + } + throw new Error('Unterminated hex string in content stream'); +} + +function findClosingDelimiter( + bytes: Uint8Array, + start: number, + closing: number[] +): number { + let pos = start; + while (true) { + pos = skipWhitespaceAndComments(bytes, pos); + if (pos >= bytes.length) { + throw new Error('Unterminated array or dictionary in content stream'); + } + if (closing.every((byte, offset) => bytes[pos + offset] === byte)) { + return pos + closing.length; + } + pos = readToken(bytes, pos).end; + } +} + +function readToken(bytes: Uint8Array, start: number): ContentToken { + const byte = bytes[start]; + + if (byte === OPEN_PAREN) { + const end = findLiteralStringEnd(bytes, start); + return { kind: 'operand', value: '', start, end }; + } + if (byte === LESS_THAN && bytes[start + 1] === LESS_THAN) { + const end = findClosingDelimiter(bytes, start + 2, [ + GREATER_THAN, + GREATER_THAN, + ]); + return { kind: 'dict', value: '', start, end }; + } + if (byte === LESS_THAN) { + const end = findHexStringEnd(bytes, start); + return { kind: 'operand', value: '', start, end }; + } + if (byte === OPEN_BRACKET) { + const end = findClosingDelimiter(bytes, start + 1, [CLOSE_BRACKET]); + return { kind: 'operand', value: '', start, end }; + } + if (byte === SLASH) { + let end = start + 1; + while (end < bytes.length && isRegularChar(bytes[end])) end++; + const name = bytesToString(bytes, start + 1, end); + return { kind: 'name', value: name, start, end }; + } + if (DELIMITERS.has(byte)) { + throw new Error('Unexpected delimiter in content stream'); + } + + let end = start; + while (end < bytes.length && isRegularChar(bytes[end])) end++; + const word = bytesToString(bytes, start, end); + const isNumber = /^[\d+.-]/.test(word); + const kind = isNumber || OPERAND_KEYWORDS.has(word) ? 'operand' : 'operator'; + return { kind, value: word, start, end }; +} + +function isFollowedByText(bytes: Uint8Array, start: number): boolean { + const end = Math.min(bytes.length, start + TEXT_CHECK_LENGTH); + for (let i = start; i < end; i++) { + const byte = bytes[i]; + const isPrintable = byte >= 0x20 && byte <= 0x7e; + if (!isPrintable && !WHITESPACE.has(byte)) return false; + } + return true; +} + +function getImageDataLength( + bytes: Uint8Array, + imageDict: Map<string, ContentToken> +): number | undefined { + const lookup = (shortKey: string, longKey: string) => + imageDict.get(shortKey) ?? imageDict.get(longKey); + const toInteger = (token: ContentToken | undefined) => + token?.kind === 'operand' && /^\d+$/.test(token.value) + ? Number(token.value) + : undefined; + + if (lookup('F', 'Filter')) return undefined; + + const isImageMask = lookup('IM', 'ImageMask')?.value === 'true'; + const colorSpace = lookup('CS', 'ColorSpace'); + const isIndexedColor = + colorSpace?.kind === 'operand' && + /^\[\s*\/(I|Indexed)[\s/]/.test( + bytesToString(bytes, colorSpace.start, colorSpace.end) + ); + + let componentsPerPixel: number | undefined; + if (isImageMask || isIndexedColor) { + componentsPerPixel = 1; + } else if (colorSpace?.kind === 'name') { + componentsPerPixel = COMPONENTS_PER_COLOR_SPACE.get(colorSpace.value); + } + + const width = toInteger(lookup('W', 'Width')); + const height = toInteger(lookup('H', 'Height')); + const bitsPerComponent = isImageMask + ? 1 + : toInteger(lookup('BPC', 'BitsPerComponent')); + + if ( + width === undefined || + height === undefined || + bitsPerComponent === undefined || + componentsPerPixel === undefined + ) { + return undefined; + } + const bytesPerRow = Math.ceil( + (width * bitsPerComponent * componentsPerPixel) / 8 + ); + return height * bytesPerRow; +} + +function isEndImageOperator(bytes: Uint8Array, pos: number): boolean { + return ( + bytes[pos] === LETTER_E && + bytes[pos + 1] === LETTER_I && + (pos + 2 >= bytes.length || !isRegularChar(bytes[pos + 2])) + ); +} + +function skipInlineImage(bytes: Uint8Array, start: number): number { + const imageDict = new Map<string, ContentToken>(); + let pendingKey: ContentToken | undefined; + let pos = start; + + while (true) { + pos = skipWhitespaceAndComments(bytes, pos); + if (pos >= bytes.length) { + throw new Error('Unterminated inline image in content stream'); + } + const token = readToken(bytes, pos); + pos = token.end; + if (token.kind === 'operator' && token.value === 'ID') break; + if (pendingKey) { + imageDict.set(pendingKey.value, token); + pendingKey = undefined; + } else if (token.kind === 'name') { + pendingKey = token; + } else { + throw new Error('Malformed inline image in content stream'); + } + } + + const dataStart = WHITESPACE.has(bytes[pos]) ? pos + 1 : pos; + const dataLength = getImageDataLength(bytes, imageDict); + + if (dataLength !== undefined) { + let end = dataStart + dataLength; + while (end < bytes.length && WHITESPACE.has(bytes[end])) end++; + if (isEndImageOperator(bytes, end)) return end + 2; + throw new Error('Malformed inline image in content stream'); + } + + for (let i = dataStart; i + 1 < bytes.length; i++) { + if (!WHITESPACE.has(bytes[i - 1]) || !isEndImageOperator(bytes, i)) { + continue; + } + if (!isFollowedByText(bytes, i + 2)) break; + return i + 2; + } + throw new Error('Unterminated inline image in content stream'); +} + +export function parseContentOperations(bytes: Uint8Array): ContentOperation[] { + const operations: ContentOperation[] = []; + let operands: ContentToken[] = []; + let pos = 0; + + while (true) { + pos = skipWhitespaceAndComments(bytes, pos); + if (pos >= bytes.length) break; + const token = readToken(bytes, pos); + pos = token.end; + if (token.kind !== 'operator') { + operands.push(token); + continue; + } + if (token.value === 'BI') pos = skipInlineImage(bytes, pos); + operations.push({ + operator: token.value, + operands, + start: operands.length > 0 ? operands[0].start : token.start, + end: pos, + }); + operands = []; + } + + if (operands.length > 0) { + operations.push({ + operator: '', + operands, + start: operands[0].start, + end: operands[operands.length - 1].end, + }); + } + return operations; +} diff --git a/src/js/utils/pymupdf-loader.ts b/src/js/utils/pymupdf-loader.ts index a4243834..0251204b 100644 --- a/src/js/utils/pymupdf-loader.ts +++ b/src/js/utils/pymupdf-loader.ts @@ -50,7 +50,6 @@ export async function loadPyMuPDF(): Promise<PyMuPDFInstance> { } const pymupdfUrl = WasmProvider.getUrl('pymupdf')!; - const gsUrl = WasmProvider.getUrl('ghostscript')!; const normalizedPymupdf = pymupdfUrl.endsWith('/') ? pymupdfUrl : `${pymupdfUrl}/`; @@ -67,7 +66,6 @@ export async function loadPyMuPDF(): Promise<PyMuPDFInstance> { cachedPyMuPDF = new module.PyMuPDF({ assetPath: `${normalizedPymupdf}assets/`, - ghostscriptUrl: gsUrl, }); await cachedPyMuPDF.load(); diff --git a/src/js/utils/redact-document-data.ts b/src/js/utils/redact-document-data.ts new file mode 100644 index 00000000..1b85421b --- /dev/null +++ b/src/js/utils/redact-document-data.ts @@ -0,0 +1,253 @@ +import { + PDFArray, + PDFDict, + PDFHexString, + PDFName, + PDFObject, + PDFRawStream, + PDFRef, + PDFStream, + PDFString, + decodePDFRawStream, +} from 'pdf-lib'; +import type { PDFDocument } from 'pdf-lib'; +import { + loadPdfDocument, + removeUnreferencedObjects, +} from './load-pdf-document.js'; + +const ANNOTATION_TEXT_KEYS = ['Contents', 'RC', 'T', 'Subj']; +const NON_TEXT_KEYS = new Set( + ['CreationDate', 'ModDate', 'M', 'ID'].map((key) => PDFName.of(key)) +); +const SIGNATURE_TYPES = new Set( + ['Sig', 'DocTimeStamp'].map((type) => PDFName.of(type)) +); +const SIGNATURE_BINARY_KEYS = new Set( + ['Contents', 'Cert'].map((key) => PDFName.of(key)) +); + +function decodeText(value: PDFObject | undefined): string | null { + if (value instanceof PDFString || value instanceof PDFHexString) { + return value.decodeText(); + } + return null; +} + +function decodeStream(stream: PDFRawStream): Uint8Array | null { + try { + return decodePDFRawStream(stream).decode(); + } catch { + return null; + } +} + +function removeTextFromDocument(doc: PDFDocument, terms: string[]): void { + const context = doc.context; + const needles = terms.map((term) => term.toLowerCase()); + const pattern = new RegExp( + [...terms] + .sort((a, b) => b.length - a.length) + .map((term) => term.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')) + .join('|'), + 'gi' + ); + const contains = (text: string) => { + const lowered = text.toLowerCase(); + return needles.some((needle) => lowered.includes(needle)); + }; + const valueContains = (value: PDFObject | undefined): boolean => { + if (value instanceof PDFArray) return value.asArray().some(valueContains); + const text = decodeText(value); + return text !== null && contains(text); + }; + const resolve = (value: PDFObject | undefined) => + value instanceof PDFRef ? context.lookup(value) : value; + + const matchedFiles = new Set<string>(); + for (const [ref, object] of context.enumerateIndirectObjects()) { + if ( + !(object instanceof PDFRawStream) || + object.dict.get(PDFName.of('Type')) !== PDFName.of('EmbeddedFile') + ) { + continue; + } + const bytes = decodeStream(object); + if (!bytes) continue; + const latin1 = new TextDecoder('latin1').decode(bytes); + const utf16 = new TextDecoder('utf-16le').decode(bytes); + if (contains(latin1) || contains(utf16)) matchedFiles.add(ref.toString()); + } + + const isMatchedFileSpec = (value: PDFObject | undefined): boolean => { + const spec = resolve(value); + if (!(spec instanceof PDFDict)) return false; + const ef = resolve(spec.get(PDFName.of('EF'))); + if (!(ef instanceof PDFDict)) return false; + return ef + .values() + .some((v) => v instanceof PDFRef && matchedFiles.has(v.toString())); + }; + + const fieldValueContains = (widget: PDFDict): boolean => { + let node: PDFObject | undefined = widget; + for (let depth = 0; node instanceof PDFDict && depth < 32; depth++) { + if (valueContains(resolve(node.get(PDFName.of('V'))))) return true; + node = resolve(node.get(PDFName.of('Parent'))); + } + return false; + }; + + let clearedFieldAppearance = false; + for (const page of doc.getPages()) { + page.node.delete(PDFName.of('Thumb')); + const annots = page.node.Annots(); + if (!annots) continue; + + const removed = new Set<string>(); + for (const entry of annots.asArray()) { + const annot = resolve(entry); + if (!(annot instanceof PDFDict)) continue; + const subtype = annot.get(PDFName.of('Subtype')); + + if (subtype === PDFName.of('Widget')) { + if (fieldValueContains(annot)) { + annot.delete(PDFName.of('AP')); + clearedFieldAppearance = true; + } + continue; + } + + const hasText = ANNOTATION_TEXT_KEYS.some((key) => + valueContains(resolve(annot.get(PDFName.of(key)))) + ); + const hasFile = + subtype === PDFName.of('FileAttachment') && + isMatchedFileSpec(annot.get(PDFName.of('FS'))); + if (hasText || hasFile) { + removed.add(entry.toString()); + const popup = annot.get(PDFName.of('Popup')); + if (popup) removed.add(popup.toString()); + } + } + + if (removed.size > 0) { + page.node.set( + PDFName.of('Annots'), + context.obj( + annots.asArray().filter((entry) => !removed.has(entry.toString())) + ) + ); + } + } + + if (clearedFieldAppearance) { + const acroForm = resolve(doc.catalog.get(PDFName.of('AcroForm'))); + if (acroForm instanceof PDFDict) { + acroForm.set(PDFName.of('NeedAppearances'), context.obj(true)); + } + } + + const pruneNameTree = (value: PDFObject | undefined, depth = 0) => { + const node = resolve(value); + if (!(node instanceof PDFDict) || depth > 32) return; + const names = resolve(node.get(PDFName.of('Names'))); + if (names instanceof PDFArray) { + const items = names.asArray(); + const kept: PDFObject[] = []; + for (let i = 0; i + 1 < items.length; i += 2) { + if (!isMatchedFileSpec(items[i + 1])) kept.push(items[i], items[i + 1]); + } + node.set(PDFName.of('Names'), context.obj(kept)); + } + const kids = resolve(node.get(PDFName.of('Kids'))); + if (kids instanceof PDFArray) { + for (const kid of kids.asArray()) pruneNameTree(kid, depth + 1); + } + }; + const pruneAssociatedFiles = (dict: PDFDict) => { + const list = resolve(dict.get(PDFName.of('AF'))); + if (!(list instanceof PDFArray)) return; + const kept = list.asArray().filter((spec) => !isMatchedFileSpec(spec)); + if (kept.length !== list.size()) { + dict.set(PDFName.of('AF'), context.obj(kept)); + } + }; + + if (matchedFiles.size > 0) { + const names = resolve(doc.catalog.get(PDFName.of('Names'))); + if (names instanceof PDFDict) { + pruneNameTree(names.get(PDFName.of('EmbeddedFiles'))); + } + for (const [, object] of context.enumerateIndirectObjects()) { + if (object instanceof PDFDict) pruneAssociatedFiles(object); + else if (object instanceof PDFStream) pruneAssociatedFiles(object.dict); + } + } + + const scrubValue = (value: PDFObject): PDFObject | null => { + const text = decodeText(value); + if (text !== null && contains(text)) { + return PDFHexString.fromText(text.replace(pattern, '')); + } + if (value instanceof PDFDict || value instanceof PDFArray) { + scrubContainer(value); + } + return null; + }; + + const scrubContainer = (container: PDFDict | PDFArray) => { + if (container instanceof PDFDict) { + const type = container.get(PDFName.of('Type')); + const isSignature = type instanceof PDFName && SIGNATURE_TYPES.has(type); + for (const [key, value] of container.entries()) { + if (NON_TEXT_KEYS.has(key)) continue; + if (isSignature && SIGNATURE_BINARY_KEYS.has(key)) continue; + const replacement = scrubValue(value); + if (replacement) container.set(key, replacement); + } + } else { + container.asArray().forEach((value, index) => { + const replacement = scrubValue(value); + if (replacement) container.set(index, replacement); + }); + } + }; + + for (const [ref, object] of context.enumerateIndirectObjects()) { + if (object instanceof PDFStream) { + scrubContainer(object.dict); + const isMetadata = + object.dict.get(PDFName.of('Type')) === PDFName.of('Metadata'); + if (isMetadata && object instanceof PDFRawStream) { + const bytes = decodeStream(object); + const xml = bytes ? new TextDecoder('utf-8').decode(bytes) : null; + if (xml !== null && contains(xml)) { + context.assign( + ref, + context.flateStream(xml.replace(pattern, ''), { + Type: 'Metadata', + Subtype: 'XML', + }) + ); + } + } + } else if (object instanceof PDFDict || object instanceof PDFArray) { + scrubContainer(object); + } + } +} + +export async function redactDocumentData( + pdf: Uint8Array, + terms: readonly string[] = [] +): Promise<Uint8Array> { + const doc = await loadPdfDocument(pdf); + const searchTerms = terms.filter((term) => term.length > 0); + if (searchTerms.length > 0) { + removeTextFromDocument(doc, searchTerms); + } else { + for (const page of doc.getPages()) page.node.delete(PDFName.of('Thumb')); + } + return removeUnreferencedObjects(await doc.save()); +} diff --git a/src/js/utils/sanitize.ts b/src/js/utils/sanitize.ts index 4fad95b8..ac54f77d 100644 --- a/src/js/utils/sanitize.ts +++ b/src/js/utils/sanitize.ts @@ -1,6 +1,24 @@ -import { PDFDocument, PDFName, PDFDict, PDFArray } from 'pdf-lib'; -import { loadPdfDocument } from './load-pdf-document.js'; -import type { PDFDocumentInternal } from '@/types'; +import { + PDFDocument, + PDFName, + PDFDict, + PDFArray, + PDFStream, + PDFObject, + PDFRef, + PDFRawStream, + PDFFlateStream, + PDFPageLeaf, + PDFContext, + decodePDFRawStream, +} from 'pdf-lib'; +import { + loadPdfDocument, + removeUnreferencedObjects, +} from './load-pdf-document.js'; +import { flattenFormsInDoc } from './flatten-annotations.js'; +import { parseContentOperations } from './pdf-content-tokens.js'; +import type { ContentOperation, PDFDocumentInternal } from '@/types'; function getCatalogDict(pdfDoc: PDFDocument): PDFDict { return pdfDoc.catalog; @@ -30,6 +48,22 @@ function getErrorMessage(e: unknown): string { return e instanceof Error ? e.message : String(e); } +function forEachDict(pdfDoc: PDFDocument, visit: (dict: PDFDict) => void) { + const walk = (value: PDFObject) => { + if (value instanceof PDFStream) { + walk(value.dict); + } else if (value instanceof PDFDict) { + visit(value); + for (const [, entry] of value.entries()) walk(entry); + } else if (value instanceof PDFArray) { + for (const entry of value.asArray()) walk(entry); + } + }; + for (const [, object] of pdfDoc.context.enumerateIndirectObjects()) { + walk(object); + } +} + export interface SanitizeOptions { flattenForms: boolean; removeMetadata: boolean; @@ -71,10 +105,11 @@ function removeMetadataFromDoc(pdfDoc: PDFDocument) { pdfDoc.setProducer(''); try { - const catalogDict = getCatalogDict(pdfDoc); - if (catalogDict.has(PDFName.of('Metadata'))) { - catalogDict.delete(PDFName.of('Metadata')); - } + forEachDict(pdfDoc, (dict) => { + dict.delete(PDFName.of('Metadata')); + dict.delete(PDFName.of('PieceInfo')); + dict.delete(PDFName.of('Thumb')); + }); } catch (e: unknown) { console.warn('Could not remove XMP metadata:', getErrorMessage(e)); } @@ -87,15 +122,6 @@ function removeMetadataFromDoc(pdfDoc: PDFDocument) { } catch (e: unknown) { console.warn('Could not remove document IDs:', getErrorMessage(e)); } - - try { - const catalogDict = getCatalogDict(pdfDoc); - if (catalogDict.has(PDFName.of('PieceInfo'))) { - catalogDict.delete(PDFName.of('PieceInfo')); - } - } catch (e: unknown) { - console.warn('Could not remove PieceInfo:', getErrorMessage(e)); - } } function removeAnnotationsFromDoc(pdfDoc: PDFDocument) { @@ -112,9 +138,72 @@ function removeAnnotationsFromDoc(pdfDoc: PDFDocument) { } } -function flattenFormsInDoc(pdfDoc: PDFDocument) { - const form = pdfDoc.getForm(); - form.flatten(); +const DANGEROUS_ACTIONS = new Set([ + 'JavaScript', + 'Launch', + 'ImportData', + 'SubmitForm', + 'GoToE', + 'Rendition', +]); + +function isDangerousAction(action: PDFDict): boolean { + const type = action.lookup(PDFName.of('S')); + return ( + action.has(PDFName.of('JS')) || + (type instanceof PDFName && DANGEROUS_ACTIONS.has(type.decodeText())) + ); +} + +function keepSafeActions( + context: PDFContext, + value: PDFObject, + seen: Set<PDFObject> +): PDFObject[] { + const resolved = context.lookup(value); + if (resolved instanceof PDFArray) { + if (seen.has(resolved)) return []; + seen.add(resolved); + return resolved + .asArray() + .flatMap((item) => keepSafeActions(context, item, seen)); + } + if (!(resolved instanceof PDFDict) || !isDangerousAction(resolved)) { + return [value]; + } + if (seen.has(resolved)) return []; + seen.add(resolved); + const next = resolved.get(PDFName.of('Next')); + return next ? keepSafeActions(context, next, seen) : []; +} + +function removeDangerousActions(context: PDFContext, dict: PDFDict) { + dict.delete(PDFName.of('AA')); + const keys = ['A', 'OpenAction', 'PA']; + if (dict.has(PDFName.of('S'))) keys.push('Next'); + for (const key of keys) { + const name = PDFName.of(key); + const value = dict.get(name); + if (value === undefined) continue; + const resolved = context.lookup(value); + if (key !== 'Next' && !(resolved instanceof PDFDict)) continue; + const safeActions = keepSafeActions(context, value, new Set()); + const originalActions = + resolved instanceof PDFArray ? resolved.asArray() : [value]; + if ( + safeActions.length === originalActions.length && + safeActions.every((item, i) => item === originalActions[i]) + ) { + continue; + } + if (safeActions.length === 0) { + dict.delete(name); + } else if (key === 'Next' && safeActions.length > 1) { + dict.set(name, context.obj(safeActions)); + } else { + dict.set(name, safeActions[0]); + } + } } function removeJavascriptFromDoc(pdfDoc: PDFDocument) { @@ -137,92 +226,13 @@ function removeJavascriptFromDoc(pdfDoc: PDFDocument) { } } - if (catalogDict.has(PDFName.of('OpenAction'))) { - catalogDict.delete(PDFName.of('OpenAction')); + const acroForm = catalogDict.lookup(PDFName.of('AcroForm')); + if (acroForm instanceof PDFDict) { + acroForm.delete(PDFName.of('XFA')); + catalogDict.delete(PDFName.of('NeedsRendering')); } - if (catalogDict.has(PDFName.of('AA'))) { - catalogDict.delete(PDFName.of('AA')); - } - - const pages = pdfDoc.getPages(); - for (const page of pages) { - try { - const pageDict = page.node; - - if (pageDict.has(PDFName.of('AA'))) { - pageDict.delete(PDFName.of('AA')); - } - - const annotRefs = pageDict.Annots()?.asArray() || []; - for (const annotRef of annotRefs) { - try { - const annot = lookupAsDict(pdfDoc, annotRef); - if (!annot) continue; - - if (annot.has(PDFName.of('A'))) { - const actionRef = annot.get(PDFName.of('A')); - try { - const actionDict = lookupAsDict(pdfDoc, actionRef); - const actionType = actionDict - ?.get(PDFName.of('S')) - ?.toString() - .substring(1); - - if (actionType === 'JavaScript') { - annot.delete(PDFName.of('A')); - } - } catch (e: unknown) { - console.warn('Could not read action:', getErrorMessage(e)); - } - } - - if (annot.has(PDFName.of('AA'))) { - annot.delete(PDFName.of('AA')); - } - } catch (e: unknown) { - console.warn( - 'Could not process annotation for JS:', - getErrorMessage(e) - ); - } - } - } catch (e: unknown) { - console.warn('Could not remove page actions:', getErrorMessage(e)); - } - } - - try { - const acroFormRef = catalogDict.get(PDFName.of('AcroForm')); - if (acroFormRef) { - const acroFormDict = lookupAsDict(pdfDoc, acroFormRef); - const fieldsRef = acroFormDict?.get(PDFName.of('Fields')); - - if (fieldsRef) { - const fieldsArray = lookupAsArray(pdfDoc, fieldsRef); - const fields = fieldsArray?.asArray() || []; - - for (const fieldRef of fields) { - try { - const field = lookupAsDict(pdfDoc, fieldRef); - if (!field) continue; - - if (field.has(PDFName.of('A'))) { - field.delete(PDFName.of('A')); - } - - if (field.has(PDFName.of('AA'))) { - field.delete(PDFName.of('AA')); - } - } catch (e: unknown) { - console.warn('Could not process field for JS:', getErrorMessage(e)); - } - } - } - } - } catch (e: unknown) { - console.warn('Could not process form fields for JS:', getErrorMessage(e)); - } + forEachDict(pdfDoc, (dict) => removeDangerousActions(pdfDoc.context, dict)); } function removeEmbeddedFilesFromDoc(pdfDoc: PDFDocument) { @@ -281,6 +291,14 @@ function removeEmbeddedFilesFromDoc(pdfDoc: PDFDocument) { } } + for (const [, object] of pdfDoc.context.enumerateIndirectObjects()) { + if (object instanceof PDFDict) { + object.delete(PDFName.of('AF')); + } else if (object instanceof PDFStream) { + object.dict.delete(PDFName.of('AF')); + } + } + const pdfDocInternal = pdfDoc as unknown as PDFDocumentInternal; if (pdfDocInternal.embeddedFiles && pdfDocInternal.embeddedFiles.length > 0) { pdfDocInternal.embeddedFiles = []; @@ -291,37 +309,410 @@ function removeEmbeddedFilesFromDoc(pdfDoc: PDFDocument) { } } -function removeLayersFromDoc(pdfDoc: PDFDocument) { - const catalogDict = getCatalogDict(pdfDoc); +function createOptionalContentVisibility(pdfDoc: PDFDocument) { + const context = pdfDoc.context; + const ocProperties = pdfDoc.catalog.lookup(PDFName.of('OCProperties')); + const config = + ocProperties instanceof PDFDict + ? ocProperties.lookup(PDFName.of('D')) + : undefined; + const getConfigGroups = (key: string) => { + const list = + config instanceof PDFDict ? config.lookup(PDFName.of(key)) : undefined; + return new Set( + list instanceof PDFArray + ? list.asArray().map((item) => context.lookup(item)) + : [] + ); + }; + const onGroups = getConfigGroups('ON'); + const offGroups = getConfigGroups('OFF'); + const hiddenByDefault = + config instanceof PDFDict && + config.lookup(PDFName.of('BaseState')) === PDFName.of('OFF'); + const isGroupVisible = (group: PDFObject) => + !offGroups.has(group) && (!hiddenByDefault || onGroups.has(group)); + const expressionCache = new Map<PDFObject, boolean | undefined>(); - if (catalogDict.has(PDFName.of('OCProperties'))) { - catalogDict.delete(PDFName.of('OCProperties')); + const isExpressionVisible = ( + value: PDFObject | undefined, + depth: number + ): boolean | undefined => { + const resolved = context.lookup(value); + if (resolved instanceof PDFDict) return isGroupVisible(resolved); + if (!(resolved instanceof PDFArray) || resolved.size() < 2 || depth > 32) { + return undefined; + } + if (expressionCache.has(resolved)) return expressionCache.get(resolved); + const operator = resolved.lookup(0); + const results = resolved + .asArray() + .slice(1) + .map((item) => isExpressionVisible(item, depth + 1)); + let result: boolean | undefined; + if (!results.every((item) => item !== undefined)) { + result = undefined; + } else if (operator === PDFName.of('And')) { + result = results.every(Boolean); + } else if (operator === PDFName.of('Or')) { + result = results.some(Boolean); + } else if (operator === PDFName.of('Not') && results.length === 1) { + result = !results[0]; + } + expressionCache.set(resolved, result); + return result; + }; + + return (value: PDFObject | undefined): boolean => { + const dict = context.lookup(value); + if (!(dict instanceof PDFDict)) return true; + if (dict.lookup(PDFName.of('Type')) !== PDFName.of('OCMD')) { + return isGroupVisible(dict); + } + if (dict.has(PDFName.of('VE'))) { + return isExpressionVisible(dict.get(PDFName.of('VE')), 0) === true; + } + const groups = dict.lookup(PDFName.of('OCGs')); + const states = ( + groups instanceof PDFArray + ? groups.asArray().map((group) => context.lookup(group)) + : [groups] + ) + .filter((group) => group instanceof PDFDict) + .map((group) => isGroupVisible(group)); + if (states.length === 0) return true; + const policy = dict.lookup(PDFName.of('P')); + if (policy === undefined || policy === PDFName.of('AnyOn')) { + return states.some(Boolean); + } + if (policy === PDFName.of('AllOn')) return states.every(Boolean); + if (policy === PDFName.of('AnyOff')) return !states.every(Boolean); + if (policy === PDFName.of('AllOff')) return !states.some(Boolean); + return false; + }; +} + +const STATE_OPERATORS = new Set( + 'q Q cm w J j M d ri i gs CS cs SC SCN sc scn G g RG rg K k BT ET Tc Tw Tz TL Tf Tr Ts Td TD Tm T* BX EX'.split( + ' ' + ) +); +const PATH_OPERATORS = new Set('m l c v y h re W W*'.split(' ')); +const PAINT_OPERATORS = new Set('S s f F f* B B* b b* n'.split(' ')); + +function readStreamBytes(stream: PDFStream): Uint8Array { + if (stream instanceof PDFRawStream) { + return decodePDFRawStream(stream).decode(); } + if (stream instanceof PDFFlateStream) return stream.getUnencodedContents(); + return stream.getContents(); +} - const pages = pdfDoc.getPages(); - for (const page of pages) { - try { - const pageDict = page.node; +const NEWLINE = new Uint8Array([0x0a]); - if (pageDict.has(PDFName.of('OCProperties'))) { - pageDict.delete(PDFName.of('OCProperties')); +function concatBytes(pieces: Uint8Array[]): Uint8Array { + const result = new Uint8Array( + pieces.reduce((size, piece) => size + piece.length, 0) + ); + let offset = 0; + for (const piece of pieces) { + result.set(piece, offset); + offset += piece.length; + } + return result; +} + +function readPageContent(page: PDFPageLeaf): Uint8Array | undefined { + const contents = page.Contents(); + if (!contents) return undefined; + const streams = + contents instanceof PDFArray ? contents.asArray() : [contents]; + return concatBytes( + streams.flatMap((item) => { + const stream = page.context.lookup(item); + return stream instanceof PDFStream + ? [readStreamBytes(stream), NEWLINE] + : []; + }) + ); +} + +function isContentStream(dict: PDFDict): boolean { + const subtype = dict.lookup(PDFName.of('Subtype')); + return ( + subtype === PDFName.of('Form') || + (subtype === undefined && + dict.has(PDFName.of('BBox')) && + !dict.has(PDFName.of('ShadingType'))) + ); +} + +function removeHiddenContent( + context: PDFContext, + bytes: Uint8Array, + resources: PDFDict | undefined, + isVisible: (value: PDFObject | undefined) => boolean +): Uint8Array | undefined { + const hiddenXObjects = new Set<PDFName>(); + const xObjects = resources?.lookup(PDFName.of('XObject')); + if (xObjects instanceof PDFDict) { + for (const [name, value] of xObjects.entries()) { + const xObject = context.lookup(value); + if ( + xObject instanceof PDFStream && + xObject.dict.has(PDFName.of('OC')) && + !isVisible(xObject.dict.get(PDFName.of('OC'))) + ) { + hiddenXObjects.add(name); } - - const resourcesRef = pageDict.get(PDFName.of('Resources')); - if (resourcesRef) { - try { - const resourcesDict = lookupAsDict(pdfDoc, resourcesRef); - if (resourcesDict?.has(PDFName.of('Properties'))) { - resourcesDict.delete(PDFName.of('Properties')); - } - } catch (e: unknown) { - console.warn('Could not access Resources:', getErrorMessage(e)); - } - } - } catch (e: unknown) { - console.warn('Could not remove page layers:', getErrorMessage(e)); } } + if ( + hiddenXObjects.size === 0 && + !new TextDecoder('latin1').decode(bytes).includes('BDC') + ) { + return undefined; + } + + const properties = resources?.lookup(PDFName.of('Properties')); + const startsHiddenSection = (operation: ContentOperation) => { + const [tag, property] = operation.operands; + if (tag?.kind !== 'name' || PDFName.of(tag.value) !== PDFName.of('OC')) { + return false; + } + const value = + property?.kind === 'name' && properties instanceof PDFDict + ? properties.get(PDFName.of(property.value)) + : undefined; + if (value === undefined) { + throw new Error('Unresolved optional content reference'); + } + return !isVisible(value); + }; + + const encoder = new TextEncoder(); + const pieces: Uint8Array[] = []; + const write = (...parts: Uint8Array[]) => pieces.push(NEWLINE, ...parts); + const writeOperation = (operation: ContentOperation) => + write(bytes.subarray(operation.start, operation.end)); + const openSections: boolean[] = []; + let hiddenDepth = 0; + let path: ContentOperation[] = []; + let changed = false; + let previousEnd = 0; + + for (const operation of parseContentOperations(bytes)) { + const { operator, operands } = operation; + const hidden = hiddenDepth > 0; + const gapStart = previousEnd; + previousEnd = operation.end; + const keepOriginal = () => + pieces.push(bytes.subarray(gapStart, operation.end)); + if (operator === 'BMC' || operator === 'BDC') { + const startsHidden = + !hidden && operator === 'BDC' && startsHiddenSection(operation); + openSections.push(startsHidden); + if (startsHidden) hiddenDepth++; + if (hidden || startsHidden) { + changed = true; + } else { + keepOriginal(); + } + continue; + } + if (operator === 'EMC') { + if (openSections.pop()) hiddenDepth--; + if (hidden) { + changed = true; + if (hiddenDepth === 0) path = []; + } else { + keepOriginal(); + } + continue; + } + if (!hidden) { + const target = operands[0]; + if ( + operator === 'Do' && + target?.kind === 'name' && + hiddenXObjects.has(PDFName.of(target.value)) + ) { + changed = true; + } else { + keepOriginal(); + } + continue; + } + changed = true; + if (STATE_OPERATORS.has(operator)) { + writeOperation(operation); + } else if (PATH_OPERATORS.has(operator)) { + path.push(operation); + } else if (PAINT_OPERATORS.has(operator)) { + if ( + path.some((item) => item.operator === 'W' || item.operator === 'W*') + ) { + path.forEach(writeOperation); + write(encoder.encode('n')); + } + path = []; + } else if (operator === "'") { + write(encoder.encode('T*')); + } else if (operator === '"' && operands.length === 3) { + write( + bytes.subarray(operands[0].start, operands[0].end), + encoder.encode(' Tw '), + bytes.subarray(operands[1].start, operands[1].end), + encoder.encode(' Tc T*') + ); + } + } + + if (!changed) return undefined; + if (hiddenDepth === 0) pieces.push(bytes.subarray(previousEnd)); + return concatBytes(pieces); +} + +function mergePageResources(pdfDoc: PDFDocument): PDFDict { + const context = pdfDoc.context; + const merged = new Map<string, Map<string, PDFObject>>(); + const conflicts = new Set<string>(); + + for (const page of pdfDoc.getPages()) { + const resources = page.node.Resources(); + for (const category of ['XObject', 'Properties']) { + const entries = resources?.lookup(PDFName.of(category)); + if (!(entries instanceof PDFDict)) continue; + const mergedEntries = merged.get(category) ?? new Map(); + merged.set(category, mergedEntries); + for (const [name, value] of entries.entries()) { + const key = `${category}/${name.decodeText()}`; + const existing = mergedEntries.get(name.decodeText()); + if (existing && existing.toString() !== value.toString()) { + conflicts.add(key); + } + mergedEntries.set(name.decodeText(), value); + } + } + } + + const resources = context.obj({}); + for (const [category, entries] of merged) { + const dict = context.obj({}); + for (const [name, value] of entries) { + if (!conflicts.has(`${category}/${name}`)) { + dict.set(PDFName.of(name), value); + } + } + resources.set(PDFName.of(category), dict); + } + return resources; +} + +function removeLayersFromDoc(pdfDoc: PDFDocument) { + const catalogDict = getCatalogDict(pdfDoc); + const context = pdfDoc.context; + const pageContents: [PDFPageLeaf, Uint8Array][] = []; + const streamContents: [PDFRef, PDFStream, Uint8Array][] = []; + const hiddenAnnots = new Set<PDFObject>(); + const hiddenXObjects = new Set<PDFObject>(); + + if (catalogDict.has(PDFName.of('OCProperties'))) { + const isVisible = createOptionalContentVisibility(pdfDoc); + + for (const page of pdfDoc.getPages()) { + const content = readPageContent(page.node); + const filtered = + content && + removeHiddenContent(context, content, page.node.Resources(), isVisible); + if (filtered) pageContents.push([page.node, filtered]); + + for (const annotRef of page.node.Annots()?.asArray() ?? []) { + const annot = context.lookup(annotRef); + if ( + annot instanceof PDFDict && + annot.has(PDFName.of('OC')) && + !isVisible(annot.get(PDFName.of('OC'))) + ) { + hiddenAnnots.add(annotRef); + } + } + } + + const inheritedResources = mergePageResources(pdfDoc); + for (const [ref, object] of context.enumerateIndirectObjects()) { + if (!(object instanceof PDFStream)) continue; + if ( + object.dict.has(PDFName.of('OC')) && + !isVisible(object.dict.get(PDFName.of('OC'))) + ) { + hiddenXObjects.add(ref); + } + if (!isContentStream(object.dict)) continue; + const resources = object.dict.lookup(PDFName.of('Resources')); + const filtered = removeHiddenContent( + context, + readStreamBytes(object), + resources instanceof PDFDict ? resources : inheritedResources, + isVisible + ); + if (filtered) streamContents.push([ref, object, filtered]); + } + } + + for (const [page, content] of pageContents) { + page.set( + PDFName.of('Contents'), + context.register(context.flateStream(content)) + ); + } + + for (const [ref, stream, content] of streamContents) { + const replacement = context.flateStream(content); + for (const [key, value] of stream.dict.entries()) { + if ( + !['Filter', 'DecodeParms', 'Length', 'DL'].includes(key.decodeText()) + ) { + replacement.dict.set(key, value); + } + } + context.assign(ref, replacement); + } + + for (const page of pdfDoc.getPages()) { + const annots = page.node.Annots(); + if (!annots) continue; + for (let i = annots.size() - 1; i >= 0; i--) { + if (hiddenAnnots.has(annots.get(i))) annots.remove(i); + } + } + + catalogDict.delete(PDFName.of('OCProperties')); + + forEachDict(pdfDoc, (dict) => { + dict.delete(PDFName.of('OCProperties')); + dict.delete(PDFName.of('OC')); + + const xObjects = dict.lookup(PDFName.of('XObject')); + if (xObjects instanceof PDFDict) { + for (const [name, value] of xObjects.entries()) { + if (hiddenXObjects.has(value)) xObjects.delete(name); + } + } + + const properties = dict.lookup(PDFName.of('Properties')); + if (properties instanceof PDFDict) { + for (const [name, value] of properties.entries()) { + const type = lookupAsDict(pdfDoc, value)?.lookup(PDFName.of('Type')); + if (type === PDFName.of('OCG') || type === PDFName.of('OCMD')) { + properties.delete(name); + } + } + if (properties.entries().length === 0) { + dict.delete(PDFName.of('Properties')); + } + } + }); } function removeLinksFromDoc(pdfDoc: PDFDocument) { @@ -554,8 +945,9 @@ function removeFontsFromDoc(pdfDoc: PDFDocument) { export async function sanitizePdf( pdfBytes: Uint8Array, options: SanitizeOptions -): Promise<{ pdfDoc: PDFDocument; bytes: Uint8Array }> { +): Promise<{ pdfDoc: PDFDocument; bytes: Uint8Array; skipped: string[] }> { const pdfDoc = await loadPdfDocument(pdfBytes); + const skipped: string[] = []; if (options.flattenForms) { try { @@ -572,6 +964,7 @@ export async function sanitizePdf( 'Could not remove AcroForm:', getErrorMessage(removeError) ); + skipped.push('flatten forms'); } } } @@ -589,6 +982,7 @@ export async function sanitizePdf( removeJavascriptFromDoc(pdfDoc); } catch (e: unknown) { console.warn(`Could not remove JavaScript: ${getErrorMessage(e)}`); + skipped.push('remove JavaScript'); } } @@ -597,6 +991,7 @@ export async function sanitizePdf( removeEmbeddedFilesFromDoc(pdfDoc); } catch (e: unknown) { console.warn(`Could not remove embedded files: ${getErrorMessage(e)}`); + skipped.push('remove embedded files'); } } @@ -605,6 +1000,7 @@ export async function sanitizePdf( removeLayersFromDoc(pdfDoc); } catch (e: unknown) { console.warn(`Could not remove layers: ${getErrorMessage(e)}`); + skipped.push('remove layers'); } } @@ -613,6 +1009,7 @@ export async function sanitizePdf( removeLinksFromDoc(pdfDoc); } catch (e: unknown) { console.warn(`Could not remove links: ${getErrorMessage(e)}`); + skipped.push('remove links'); } } @@ -621,6 +1018,7 @@ export async function sanitizePdf( removeStructureTreeFromDoc(pdfDoc); } catch (e: unknown) { console.warn(`Could not remove structure tree: ${getErrorMessage(e)}`); + skipped.push('remove the structure tree'); } } @@ -629,6 +1027,7 @@ export async function sanitizePdf( removeMarkInfoFromDoc(pdfDoc); } catch (e: unknown) { console.warn(`Could not remove MarkInfo: ${getErrorMessage(e)}`); + skipped.push('remove MarkInfo'); } } @@ -637,9 +1036,14 @@ export async function sanitizePdf( removeFontsFromDoc(pdfDoc); } catch (e: unknown) { console.warn(`Could not remove fonts: ${getErrorMessage(e)}`); + skipped.push('remove fonts'); } } const savedBytes = await pdfDoc.save(); - return { pdfDoc, bytes: new Uint8Array(savedBytes) }; + return { + pdfDoc, + bytes: await removeUnreferencedObjects(savedBytes), + skipped, + }; } diff --git a/src/js/utils/translation-html.ts b/src/js/utils/translation-html.ts new file mode 100644 index 00000000..6dfae3c3 --- /dev/null +++ b/src/js/utils/translation-html.ts @@ -0,0 +1,7 @@ +import DOMPurify from 'dompurify'; + +const ALLOWED_TAGS = ['br', 'strong', 'em']; + +export function sanitizeTranslationHtml(html: string): string { + return DOMPurify.sanitize(html, { ALLOWED_TAGS, ALLOWED_ATTR: [] }); +} diff --git a/src/js/utils/wasm-provider.ts b/src/js/utils/wasm-provider.ts index df102070..d3decaa7 100644 --- a/src/js/utils/wasm-provider.ts +++ b/src/js/utils/wasm-provider.ts @@ -56,6 +56,16 @@ function collectBuiltinTrustedHosts(): Set<string> { const BUILTIN_TRUSTED_HOSTS = collectBuiltinTrustedHosts(); +function isOutdatedCdnPin(packageName: WasmPackage, url: string): boolean { + const current = CDN_DEFAULTS[packageName]; + const packagePrefix = current.slice(0, current.lastIndexOf('@') + 1); + return url !== current && url.startsWith(packagePrefix); +} + +function withTrailingSlash(url: string): string { + return url.endsWith('/') ? url : `${url}/`; +} + class WasmProviderManager { private config: WasmProviderConfig; private validationCache: Map<WasmPackage, boolean> = new Map(); @@ -80,7 +90,12 @@ class WasmProviderManager { for (const key of ['pymupdf', 'ghostscript', 'cpdf'] as WasmPackage[]) { const url = parsed[key]; if (typeof url !== 'string') continue; - if (this.isTrustedUrl(url)) { + if (isOutdatedCdnPin(key, url)) { + dropped = true; + console.warn( + `[WasmProvider] Ignoring outdated stored URL for ${key}: ${url}. Using the current default instead.` + ); + } else if (this.isTrustedUrl(url)) { safe[key] = url; } else { dropped = true; @@ -134,11 +149,16 @@ class WasmProviderManager { } setUrl(packageName: WasmPackage, url: string): void { - const normalizedUrl = url.endsWith('/') ? url : `${url}/`; + const normalizedUrl = withTrailingSlash(url); const host = hostnameOf(normalizedUrl); if (!host) { throw new Error('Invalid URL'); } + const envDefault = this.getEnvDefault(packageName); + if (envDefault && normalizedUrl === withTrailingSlash(envDefault)) { + this.removeUrl(packageName); + return; + } this.trustedHosts.add(host); this.config[packageName] = normalizedUrl; this.validationCache.delete(packageName); @@ -321,7 +341,6 @@ class WasmProviderManager { 'Extract Images', 'Extract Tables', 'EPUB/MOBI/FB2/XPS/CBZ to PDF', - 'Image Compression', 'Deskew PDF', 'PDF Layers', ], diff --git a/src/js/workflow/nodes/base-node.ts b/src/js/workflow/nodes/base-node.ts index 118aebbc..9a0cc6a6 100644 --- a/src/js/workflow/nodes/base-node.ts +++ b/src/js/workflow/nodes/base-node.ts @@ -1,5 +1,6 @@ import { ClassicPreset } from 'rete'; import type { NodeCategory, NodeMeta, SocketData } from '../types'; +import { getControlSpec, sanitizeWithControlSpec } from './control-specs'; export abstract class BaseWorkflowNode extends ClassicPreset.Node { abstract readonly category: NodeCategory; @@ -41,6 +42,10 @@ export abstract class BaseWorkflowNode extends ClassicPreset.Node { if (typeof value !== typeof current) { return current; } - return value; + return sanitizeWithControlSpec( + getControlSpec(this.nodeType, key), + value, + current + ); } } diff --git a/src/js/workflow/nodes/compress-node.ts b/src/js/workflow/nodes/compress-node.ts index 0f82b44d..e5adf3fa 100644 --- a/src/js/workflow/nodes/compress-node.ts +++ b/src/js/workflow/nodes/compress-node.ts @@ -4,11 +4,10 @@ import { pdfSocket } from '../sockets'; import type { SocketData } from '../types'; import { requirePdfInput, processBatch } from '../types'; import { - performCondenseCompression, - performPhotonCompression, + compressWithHyper, + resolveCompressLevel, + targetSizeToBytes, } from '../../utils/compress.js'; -import type { CondenseCustomSettings } from '../../utils/compress.js'; -import { isPyMuPDFAvailable } from '../../utils/pymupdf-loader.js'; import { loadPdfDocument } from '../../utils/load-pdf-document.js'; export class CompressNode extends BaseWorkflowNode { @@ -23,42 +22,25 @@ export class CompressNode extends BaseWorkflowNode { 'pdf', new ClassicPreset.Output(pdfSocket, 'Compressed PDF') ); - this.addControl( - 'algorithm', - new ClassicPreset.InputControl('text', { initial: 'condense' }) - ); this.addControl( 'compressionLevel', - new ClassicPreset.InputControl('text', { initial: 'balanced' }) + new ClassicPreset.InputControl('text', { initial: 'medium' }) ); this.addControl( - 'imageQuality', - new ClassicPreset.InputControl('number', { initial: 75 }) - ); - this.addControl( - 'dpiTarget', - new ClassicPreset.InputControl('number', { initial: 96 }) - ); - this.addControl( - 'dpiThreshold', - new ClassicPreset.InputControl('number', { initial: 150 }) - ); - this.addControl( - 'removeMetadata', - new ClassicPreset.InputControl('text', { initial: 'true' }) - ); - this.addControl( - 'subsetFonts', - new ClassicPreset.InputControl('text', { initial: 'true' }) + 'targetKilobytes', + new ClassicPreset.InputControl('number', { initial: 0 }) ); this.addControl( 'convertToGrayscale', new ClassicPreset.InputControl('text', { initial: 'false' }) ); - this.addControl( - 'removeThumbnails', - new ClassicPreset.InputControl('text', { initial: 'true' }) - ); + } + + sanitizeControlValue(key: string, value: unknown): unknown { + if (key === 'compressionLevel' && typeof value === 'string') { + return super.sanitizeControlValue(key, resolveCompressLevel(value)); + } + return super.sanitizeControlValue(key, value); } private getTextControl(key: string, fallback: string): string { @@ -80,41 +62,24 @@ export class CompressNode extends BaseWorkflowNode { ): Promise<Record<string, SocketData>> { const pdfInputs = requirePdfInput(inputs, 'Compress'); - const algorithm = this.getTextControl('algorithm', 'condense'); - const level = this.getTextControl('compressionLevel', 'balanced'); - const customSettings: CondenseCustomSettings = { - imageQuality: this.getNumberControl('imageQuality', 75), - dpiTarget: this.getNumberControl('dpiTarget', 96), - dpiThreshold: this.getNumberControl('dpiThreshold', 150), - removeMetadata: this.getTextControl('removeMetadata', 'true') === 'true', - subsetFonts: this.getTextControl('subsetFonts', 'true') === 'true', - convertToGrayscale: - this.getTextControl('convertToGrayscale', 'false') === 'true', - removeThumbnails: - this.getTextControl('removeThumbnails', 'true') === 'true', - }; + const level = resolveCompressLevel( + this.getTextControl('compressionLevel', 'medium') + ); + const grayscale = + this.getTextControl('convertToGrayscale', 'false') === 'true'; + const targetSizeBytes = targetSizeToBytes( + this.getNumberControl('targetKilobytes', 0), + 'kb' + ); return { pdf: await processBatch(pdfInputs, async (input) => { - const arrayBuffer = input.bytes.buffer.slice( - input.bytes.byteOffset, - input.bytes.byteOffset + input.bytes.byteLength - ) as ArrayBuffer; - - let pdfBytes: Uint8Array; - - if (algorithm === 'condense' && isPyMuPDFAvailable()) { - const blob = new Blob([arrayBuffer], { type: 'application/pdf' }); - const result = await performCondenseCompression( - blob, - level, - customSettings - ); - pdfBytes = new Uint8Array(await result.blob.arrayBuffer()); - } else { - pdfBytes = await performPhotonCompression(arrayBuffer, level); - } - + const result = await compressWithHyper(input.bytes.slice().buffer, { + preset: level, + options: { grayscale }, + targetSizeBytes, + }); + const pdfBytes = result.data; const document = await loadPdfDocument(pdfBytes); return { diff --git a/src/js/workflow/nodes/control-specs.ts b/src/js/workflow/nodes/control-specs.ts new file mode 100644 index 00000000..8d3c347c --- /dev/null +++ b/src/js/workflow/nodes/control-specs.ts @@ -0,0 +1,378 @@ +import type { WorkflowControlOption, WorkflowControlSpec } from '@/types'; +import { getAvailableTesseractLanguageEntries } from '../../utils/tesseract-language-availability.js'; + +export const MAX_TEXT_CONTROL_LENGTH = 1000; + +const BOOLEAN: WorkflowControlSpec = { kind: 'boolean' }; +const COLOR: WorkflowControlSpec = { kind: 'color' }; +const PASSWORD: WorkflowControlSpec = { kind: 'password' }; +const HEX_COLOR = /^#[0-9a-f]{6}$/i; + +function toOptions(entries: [string, string][]): WorkflowControlOption[] { + return entries.map(([value, label]) => ({ value, label })); +} + +function selectSpec(...entries: [string, string][]): WorkflowControlSpec { + return { kind: 'select', options: toOptions(entries) }; +} + +function numberSpec( + min: number, + max: number, + step?: number +): WorkflowControlSpec { + return { kind: 'number', min, max, step }; +} + +const PAGE_ORIENTATION = selectSpec( + ['auto', 'Auto (Keep Original)'], + ['portrait', 'Portrait'], + ['landscape', 'Landscape'] +); + +const PAGE_COORDINATE = numberSpec(0, 14400); + +const CONTROL_SPECS: Record<string, Record<string, WorkflowControlSpec>> = { + AddBlankPageNode: { + blankPosition: selectSpec( + ['end', 'End'], + ['start', 'Beginning'], + ['after', 'After Page...'] + ), + afterPage: numberSpec(1, 100000, 1), + count: numberSpec(1, 100, 1), + }, + AdjustColorsNode: { + brightness: numberSpec(-100, 100, 1), + contrast: numberSpec(-100, 100, 1), + saturation: numberSpec(-100, 100, 1), + hueShift: numberSpec(0, 360, 1), + temperature: numberSpec(-50, 50, 1), + tint: numberSpec(-50, 50, 1), + gamma: numberSpec(0.2, 5, 0.1), + sepia: numberSpec(0, 100, 1), + }, + AlternateMergeNode: { + retainPageLabels: BOOLEAN, + }, + BackgroundColorNode: { + color: COLOR, + }, + BookletNode: { + gridMode: selectSpec( + ['1x2', '1x2 (Booklet)'], + ['2x2', '2x2 (4-up)'], + ['2x4', '2x4 (8-up)'], + ['4x4', '4x4 (16-up)'] + ), + paperSize: selectSpec( + ['Letter', 'Letter'], + ['A4', 'A4'], + ['A3', 'A3'], + ['Tabloid', 'Tabloid'], + ['Legal', 'Legal'] + ), + orientation: PAGE_ORIENTATION, + }, + CombineSinglePageNode: { + orientation: selectSpec( + ['vertical', 'Vertical'], + ['horizontal', 'Horizontal'] + ), + spacing: numberSpec(0, 1000), + backgroundColor: COLOR, + separator: BOOLEAN, + separatorThickness: numberSpec(0.5, 50, 0.5), + separatorColor: COLOR, + }, + CompressNode: { + compressionLevel: selectSpec( + ['low', 'Low (Best Quality)'], + ['medium', 'Medium (Recommended)'], + ['high', 'High (Smallest Files)'], + ['lossless', 'Lossless (No Quality Loss)'] + ), + targetKilobytes: numberSpec(0, 1048576, 1), + convertToGrayscale: BOOLEAN, + }, + CropNode: { + top: PAGE_COORDINATE, + bottom: PAGE_COORDINATE, + left: PAGE_COORDINATE, + right: PAGE_COORDINATE, + }, + DecryptNode: { + password: PASSWORD, + }, + DeskewNode: { + skewThreshold: selectSpec( + ['0.1', '0.1° (Very Sensitive)'], + ['0.5', '0.5° (Default)'], + ['1.0', '1.0° (Normal)'], + ['2.0', '2.0° (Less Sensitive)'] + ), + processingDpi: selectSpec( + ['100', '100 (Fast)'], + ['150', '150 (Default)'], + ['200', '200 (Better)'], + ['300', '300 (Best Quality)'] + ), + }, + DividePagesNode: { + direction: selectSpec( + ['vertical', 'Vertical'], + ['horizontal', 'Horizontal'] + ), + }, + EmailToPdfNode: { + pageSize: selectSpec( + ['a4', 'A4'], + ['letter', 'Letter'], + ['legal', 'Legal'] + ), + includeCcBcc: BOOLEAN, + includeAttachments: BOOLEAN, + }, + EncryptNode: { + userPassword: PASSWORD, + ownerPassword: PASSWORD, + }, + FixPageSizeNode: { + targetSize: selectSpec( + ['A4', 'A4'], + ['Letter', 'Letter'], + ['Legal', 'Legal'], + ['A3', 'A3'], + ['A5', 'A5'], + ['Tabloid', 'Tabloid'], + ['Custom', 'Custom'] + ), + orientation: PAGE_ORIENTATION, + scalingMode: selectSpec( + ['fit', 'Fit (keep full page visible)'], + ['fill', 'Fill (cover full target page)'] + ), + backgroundColor: COLOR, + customWidth: numberSpec(1, 5080), + customHeight: numberSpec(1, 5080), + customUnits: selectSpec(['mm', 'Millimeters (mm)'], ['in', 'Inches (in)']), + }, + HeaderFooterNode: { + fontSize: numberSpec(4, 72, 1), + color: COLOR, + }, + MergeNode: { + retainPageLabels: BOOLEAN, + }, + NUpNode: { + pagesPerSheet: selectSpec(['2', '2'], ['4', '4'], ['9', '9'], ['16', '16']), + orientation: PAGE_ORIENTATION, + margins: BOOLEAN, + border: BOOLEAN, + borderColor: COLOR, + }, + OCRNode: { + language: { + kind: 'multiselect', + options: toOptions(getAvailableTesseractLanguageEntries()), + separator: '+', + }, + resolution: selectSpec( + ['2.0', 'Standard (192 DPI)'], + ['3.0', 'High (288 DPI)'], + ['4.0', 'Ultra (384 DPI)'] + ), + binarize: BOOLEAN, + }, + PageNumbersNode: { + position: selectSpec( + ['bottom-center', 'Bottom Center'], + ['bottom-left', 'Bottom Left'], + ['bottom-right', 'Bottom Right'], + ['top-center', 'Top Center'], + ['top-left', 'Top Left'], + ['top-right', 'Top Right'] + ), + fontSize: numberSpec(4, 72, 1), + numberFormat: selectSpec( + ['simple', 'Simple (1, 2, 3)'], + ['page_x_of_y', 'Page X of Y'] + ), + color: COLOR, + }, + PdfToImagesNode: { + format: selectSpec( + ['jpg', 'JPG'], + ['png', 'PNG'], + ['webp', 'WebP'], + ['svg', 'SVG'] + ), + quality: numberSpec(10, 100, 1), + dpi: numberSpec(72, 600, 1), + }, + PdfToPdfANode: { + level: selectSpec( + ['PDF/A-1b', 'PDF/A-1b (Strict, no transparency)'], + ['PDF/A-2b', 'PDF/A-2b (Recommended)'], + ['PDF/A-3b', 'PDF/A-3b (Modern, allows attachments)'] + ), + preFlatten: BOOLEAN, + }, + PosterizeNode: { + rows: numberSpec(1, 8, 1), + cols: numberSpec(1, 8, 1), + }, + RasterizeNode: { + rasterizeDpi: selectSpec( + ['72', '72 (Screen)'], + ['150', '150 (Default)'], + ['200', '200 (Good)'], + ['300', '300 (Print)'], + ['600', '600 (High Quality)'] + ), + imageFormat: selectSpec( + ['png', 'PNG (Lossless)'], + ['jpeg', 'JPEG (Smaller file size)'] + ), + grayscale: BOOLEAN, + }, + RedactNode: { + redactMode: selectSpec( + ['text', 'Search Text'], + ['area', 'Area (Coordinates)'] + ), + x0: PAGE_COORDINATE, + y0: PAGE_COORDINATE, + x1: PAGE_COORDINATE, + y1: PAGE_COORDINATE, + fillColor: COLOR, + }, + RemoveBlankPagesNode: { + threshold: numberSpec(0.1, 5, 0.1), + }, + RotateNode: { + angle: selectSpec( + ['90', '90° Clockwise'], + ['180', '180°'], + ['270', '90° Counter-clockwise'] + ), + }, + SanitizeNode: { + flattenForms: BOOLEAN, + removeMetadata: BOOLEAN, + removeAnnotations: BOOLEAN, + removeJavascript: BOOLEAN, + removeEmbeddedFiles: BOOLEAN, + removeLayers: BOOLEAN, + removeLinks: BOOLEAN, + removeStructureTree: BOOLEAN, + removeMarkInfo: BOOLEAN, + removeFonts: BOOLEAN, + }, + ScannerEffectNode: { + grayscale: BOOLEAN, + border: BOOLEAN, + rotation: numberSpec(-5, 5, 0.1), + rotationVariance: numberSpec(0, 3, 0.1), + brightness: numberSpec(-50, 50, 1), + contrast: numberSpec(-50, 50, 1), + blur: numberSpec(0, 3, 0.1), + noise: numberSpec(0, 50, 1), + yellowish: numberSpec(0, 50, 1), + resolution: numberSpec(72, 300, 1), + }, + TableOfContentsNode: { + fontSize: numberSpec(10, 72, 1), + fontFamily: selectSpec( + ['0', 'Times Roman'], + ['1', 'Times Bold'], + ['2', 'Times Italic'], + ['3', 'Times Bold Italic'], + ['4', 'Helvetica'], + ['5', 'Helvetica Bold'], + ['6', 'Helvetica Oblique'], + ['7', 'Helvetica Bold Oblique'], + ['8', 'Courier'], + ['9', 'Courier Bold'], + ['10', 'Courier Oblique'], + ['11', 'Courier Bold Oblique'] + ), + }, + TextToPdfNode: { + fontSize: numberSpec(6, 72, 1), + fontFamily: selectSpec( + ['helv', 'Helvetica'], + ['times', 'Times Roman'], + ['cour', 'Courier'], + ['tiro', 'Times Italic'] + ), + fontColor: COLOR, + }, + WatermarkNode: { + fontSize: numberSpec(10, 200, 1), + color: COLOR, + opacity: numberSpec(0, 100, 1), + angle: numberSpec(-180, 180, 1), + position: selectSpec( + ['center', 'Center'], + ['top-left', 'Top Left'], + ['top', 'Top'], + ['top-right', 'Top Right'], + ['left', 'Left'], + ['right', 'Right'], + ['bottom-left', 'Bottom Left'], + ['bottom', 'Bottom'], + ['bottom-right', 'Bottom Right'] + ), + tileGapX: numberSpec(0, 200, 1), + tileGapY: numberSpec(0, 200, 1), + }, +}; + +export function getControlSpec( + nodeType: string, + key: string +): WorkflowControlSpec | undefined { + if (!Object.hasOwn(CONTROL_SPECS, nodeType)) return undefined; + const specs = CONTROL_SPECS[nodeType]; + return Object.hasOwn(specs, key) ? specs[key] : undefined; +} + +export function sanitizeWithControlSpec( + spec: WorkflowControlSpec | undefined, + value: unknown, + fallback: unknown +): unknown { + switch (spec?.kind) { + case 'password': + return ''; + case 'select': + return spec.options.some((option) => option.value === String(value)) + ? value + : fallback; + case 'multiselect': { + if (typeof value !== 'string') return fallback; + const allowed = new Set(spec.options.map((option) => option.value)); + const kept = [...new Set(value.split(spec.separator))].filter((part) => + allowed.has(part) + ); + return kept.length > 0 ? kept.join(spec.separator) : fallback; + } + case 'number': + return typeof value === 'number' && Number.isFinite(value) + ? Math.min(spec.max, Math.max(spec.min, value)) + : fallback; + case 'boolean': + return value === 'true' || value === 'false' ? value : fallback; + case 'color': + return typeof value === 'string' && HEX_COLOR.test(value) + ? value + : fallback; + default: + if (typeof value === 'string') { + return value.slice(0, MAX_TEXT_CONTROL_LENGTH); + } + if (typeof value === 'number' && Number.isFinite(value)) return value; + return fallback; + } +} diff --git a/src/js/workflow/nodes/flatten-node.ts b/src/js/workflow/nodes/flatten-node.ts index 0791a5e9..2db999e4 100644 --- a/src/js/workflow/nodes/flatten-node.ts +++ b/src/js/workflow/nodes/flatten-node.ts @@ -3,8 +3,14 @@ import { BaseWorkflowNode } from './base-node'; import { pdfSocket } from '../sockets'; import type { SocketData } from '../types'; import { requirePdfInput, processBatch } from '../types'; -import { flattenAnnotations } from '../../utils/flatten-annotations.js'; -import { loadPdfDocument } from '../../utils/load-pdf-document.js'; +import { + flattenAnnotations, + flattenFormsInDoc, +} from '../../utils/flatten-annotations.js'; +import { + loadPdfDocument, + removeUnreferencedObjects, +} from '../../utils/load-pdf-document.js'; export class FlattenNode extends BaseWorkflowNode { readonly category = 'Secure PDF' as const; @@ -27,8 +33,7 @@ export class FlattenNode extends BaseWorkflowNode { const pdfDoc = await loadPdfDocument(input.bytes); try { - const form = pdfDoc.getForm(); - form.flatten(); + flattenFormsInDoc(pdfDoc); } catch (err) { console.error('Flatten form error (may have no forms):', err); } @@ -39,11 +44,11 @@ export class FlattenNode extends BaseWorkflowNode { console.error('Flatten annotations error:', err); } - const pdfBytes = await pdfDoc.save(); + const pdfBytes = await removeUnreferencedObjects(await pdfDoc.save()); return { type: 'pdf', document: pdfDoc, - bytes: new Uint8Array(pdfBytes), + bytes: pdfBytes, filename: input.filename.replace(/\.pdf$/i, '_flattened.pdf'), }; }), diff --git a/src/js/workflow/nodes/pdf-to-docx-node.ts b/src/js/workflow/nodes/pdf-to-docx-node.ts index 501aa753..37ad17ac 100644 --- a/src/js/workflow/nodes/pdf-to-docx-node.ts +++ b/src/js/workflow/nodes/pdf-to-docx-node.ts @@ -5,6 +5,7 @@ import type { SocketData } from '../types'; import { requirePdfInput, extractAllPdfs } from '../types'; import { downloadFile } from '../../utils/helpers.js'; import { loadPyMuPDF } from '../../utils/pymupdf-loader.js'; +import { normalizeCmykForDocx } from '../../utils/ghostscript-loader.js'; export class PdfToDocxNode extends BaseWorkflowNode { readonly category = 'Output' as const; @@ -27,7 +28,9 @@ export class PdfToDocxNode extends BaseWorkflowNode { const blob = new Blob([new Uint8Array(allPdfs[0].bytes)], { type: 'application/pdf', }); - const docxBlob = await pymupdf.pdfToDocx(blob); + const docxBlob = await pymupdf.pdfToDocx( + await normalizeCmykForDocx(blob) + ); const name = allPdfs[0].filename.replace(/\.pdf$/i, '') + '.docx'; downloadFile(docxBlob, name); } else { @@ -37,7 +40,9 @@ export class PdfToDocxNode extends BaseWorkflowNode { const blob = new Blob([new Uint8Array(pdf.bytes)], { type: 'application/pdf', }); - const docxBlob = await pymupdf.pdfToDocx(blob); + const docxBlob = await pymupdf.pdfToDocx( + await normalizeCmykForDocx(blob) + ); const name = pdf.filename.replace(/\.pdf$/i, '') + '.docx'; const arrayBuffer = await docxBlob.arrayBuffer(); zip.file(name, arrayBuffer); diff --git a/src/js/workflow/nodes/redact-node.ts b/src/js/workflow/nodes/redact-node.ts index d9175705..ced114b4 100644 --- a/src/js/workflow/nodes/redact-node.ts +++ b/src/js/workflow/nodes/redact-node.ts @@ -6,6 +6,7 @@ import { requirePdfInput, processBatch } from '../types'; import { loadPyMuPDF } from '../../utils/pymupdf-loader.js'; import { hexToRgb } from '../../utils/helpers.js'; import { loadPdfDocument } from '../../utils/load-pdf-document.js'; +import { redactDocumentData } from '../../utils/redact-document-data.js'; import { wfError } from '../errors'; export class RedactNode extends BaseWorkflowNode { @@ -106,9 +107,14 @@ export class RedactNode extends BaseWorkflowNode { } } - const resultBytes = new Uint8Array(doc.save()); + const redactedBytes = new Uint8Array(doc.save()); doc.close(); + const resultBytes = await redactDocumentData( + redactedBytes, + mode === 'text' ? [searchText] : [] + ); + const resultDoc = await loadPdfDocument(resultBytes); return { diff --git a/src/js/workflow/nodes/registry.ts b/src/js/workflow/nodes/registry.ts index 2525c2a3..ac19cd66 100644 --- a/src/js/workflow/nodes/registry.ts +++ b/src/js/workflow/nodes/registry.ts @@ -557,7 +557,7 @@ export const nodeRegistry: Record<string, NodeRegistryEntry> = { icon: 'ph-lock', description: 'Encrypt PDF with password', factory: () => new EncryptNode(), - toolPageId: 'encrypt-pdf', + toolPageId: 'protect-pdf', }, DecryptNode: { label: 'Decrypt', @@ -565,7 +565,7 @@ export const nodeRegistry: Record<string, NodeRegistryEntry> = { icon: 'ph-lock-open', description: 'Remove PDF password protection', factory: () => new DecryptNode(), - toolPageId: 'decrypt-pdf', + toolPageId: 'unlock-pdf', }, SanitizeNode: { label: 'Sanitize', @@ -653,7 +653,7 @@ export const nodeRegistry: Record<string, NodeRegistryEntry> = { icon: 'ph-microsoft-word-logo', description: 'Convert PDF to Word document', factory: () => new PdfToDocxNode(), - toolPageId: 'pdf-to-docx', + toolPageId: 'pdf-to-word', }, PdfToXlsxNode: { label: 'PDF to XLSX', @@ -698,8 +698,8 @@ export const nodeRegistry: Record<string, NodeRegistryEntry> = { }; export function createNodeByType(type: string): BaseWorkflowNode | null { + if (!Object.hasOwn(nodeRegistry, type)) return null; const entry = nodeRegistry[type]; - if (!entry) return null; const node = entry.factory(); node.nodeType = type; node.label = translateNodeLabel(type, entry); diff --git a/src/js/workflow/nodes/remove-annotations-node.ts b/src/js/workflow/nodes/remove-annotations-node.ts index 34ae9418..d7ca76c4 100644 --- a/src/js/workflow/nodes/remove-annotations-node.ts +++ b/src/js/workflow/nodes/remove-annotations-node.ts @@ -4,7 +4,10 @@ import { pdfSocket } from '../sockets'; import type { SocketData } from '../types'; import { requirePdfInput, processBatch } from '../types'; import { PDFName } from 'pdf-lib'; -import { loadPdfDocument } from '../../utils/load-pdf-document.js'; +import { + loadPdfDocument, + removeUnreferencedObjects, +} from '../../utils/load-pdf-document.js'; export class RemoveAnnotationsNode extends BaseWorkflowNode { readonly category = 'Edit & Annotate' as const; @@ -34,11 +37,11 @@ export class RemoveAnnotationsNode extends BaseWorkflowNode { } } - const pdfBytes = await pdfDoc.save(); + const pdfBytes = await removeUnreferencedObjects(await pdfDoc.save()); return { type: 'pdf', document: pdfDoc, - bytes: new Uint8Array(pdfBytes), + bytes: pdfBytes, filename: input.filename.replace(/\.pdf$/i, '_clean.pdf'), }; }), diff --git a/src/js/workflow/nodes/remove-blank-pages-node.ts b/src/js/workflow/nodes/remove-blank-pages-node.ts index e434df4c..c7fd74cc 100644 --- a/src/js/workflow/nodes/remove-blank-pages-node.ts +++ b/src/js/workflow/nodes/remove-blank-pages-node.ts @@ -19,7 +19,7 @@ export class RemoveBlankPagesNode extends BaseWorkflowNode { this.addOutput('pdf', new ClassicPreset.Output(pdfSocket, 'PDF')); this.addControl( 'threshold', - new ClassicPreset.InputControl('number', { initial: 250 }) + new ClassicPreset.InputControl('number', { initial: 5 }) ); } diff --git a/src/js/workflow/nodes/sanitize-node.ts b/src/js/workflow/nodes/sanitize-node.ts index b0827e22..bab91ad9 100644 --- a/src/js/workflow/nodes/sanitize-node.ts +++ b/src/js/workflow/nodes/sanitize-node.ts @@ -84,6 +84,11 @@ export class SanitizeNode extends BaseWorkflowNode { return { pdf: await processBatch(pdfInputs, async (input) => { const result = await sanitizePdf(input.bytes, options); + if (result.skipped.length > 0) { + console.warn( + `[Sanitize] ${input.filename}: could not ${result.skipped.join(', ')}` + ); + } return { type: 'pdf', document: result.pdfDoc, diff --git a/src/js/workflow/serialization.ts b/src/js/workflow/serialization.ts index 37b87438..a9b2f157 100644 --- a/src/js/workflow/serialization.ts +++ b/src/js/workflow/serialization.ts @@ -3,6 +3,7 @@ import type { AreaPlugin } from 'rete-area-plugin'; import type { ClassicScheme, LitArea2D } from '@retejs/lit-plugin'; import type { BaseWorkflowNode } from './nodes/base-node'; import { createNodeByType } from './nodes/registry'; +import { getControlSpec } from './nodes/control-specs'; import { ClassicPreset } from 'rete'; import type { SerializedWorkflow, @@ -18,6 +19,24 @@ function getNodeType(node: BaseWorkflowNode): string | null { return node.nodeType || null; } +function clearPasswordControls(workflow: SerializedWorkflow): boolean { + let cleared = false; + for (const node of Array.isArray(workflow?.nodes) ? workflow.nodes : []) { + const controls = node?.controls; + if (!controls || typeof controls !== 'object') continue; + for (const key of Object.keys(controls)) { + if ( + controls[key] !== '' && + getControlSpec(String(node.type), key)?.kind === 'password' + ) { + controls[key] = ''; + cleared = true; + } + } + } + return cleared; +} + function serializeWorkflow( editor: NodeEditor<ClassicScheme>, area: AreaPlugin<ClassicScheme, AreaExtra> @@ -56,11 +75,13 @@ function serializeWorkflow( }); } - return { + const workflow: SerializedWorkflow = { version: WORKFLOW_VERSION, nodes, connections, - } as SerializedWorkflow; + }; + clearPasswordControls(workflow); + return workflow; } async function deserializeWorkflow( @@ -96,6 +117,7 @@ async function deserializeWorkflow( } for (const [key, value] of Object.entries(serializedNode.controls || {})) { + if (!Object.hasOwn(node.controls, key)) continue; const control = node.controls[key]; if (control && 'value' in control) { (control as { value: unknown }).value = node.sanitizeControlValue( @@ -144,7 +166,19 @@ function getStoredTemplates(): StoredTemplates { const json = localStorage.getItem(TEMPLATES_KEY); if (!json) return {}; try { - return JSON.parse(json) as StoredTemplates; + const templates = JSON.parse(json) as StoredTemplates; + let cleared = false; + for (const template of Object.values(templates)) { + if (clearPasswordControls(template)) cleared = true; + } + if (cleared) { + try { + localStorage.setItem(TEMPLATES_KEY, JSON.stringify(templates)); + } catch (e) { + console.warn('Failed to clear stored workflow passwords:', e); + } + } + return templates; } catch { return {}; } diff --git a/src/pages/add-attachments.html b/src/pages/add-attachments.html index fb4d1b7c..b9f9f4d1 100644 --- a/src/pages/add-attachments.html +++ b/src/pages/add-attachments.html @@ -11,7 +11,7 @@ /> <meta name="description" - content="★ Add Attachments online free - Add attachments PDFs easily ★ No signup ★ Unlimited files ★ Privacy-first ★ Works in browser ★ Fast & secure" + content="Embed one or more files into your PDF. Free, no uploads, no signup. Runs entirely in your browser." /> <meta name="keywords" content="add attachments, online pdf" /> <meta name="author" content="BentoPDF" /> @@ -35,7 +35,7 @@ /> <meta property="og:description" - content="★ Add Attachments online free - Add attachments PDFs easily ★ No signup ★ Unlimited files ★ Privacy-first ★ Works in browser ★ Fast & secure" + content="Embed one or more files into your PDF. Free, no uploads, no signup. Runs entirely in your browser." /> <meta property="og:image" @@ -54,7 +54,7 @@ <meta name="twitter:title" content="Add Attachments Free" /> <meta name="twitter:description" - content="★ Add Attachments online free - Add attachments PDFs easily ★ No signup ★ Unlimited files ★ Privacy-first ★ Works in browser" + content="Embed one or more files into your PDF. Free, no uploads, no signup. Runs entirely in your browser." /> <meta name="twitter:image" @@ -68,10 +68,6 @@ <meta name="apple-mobile-web-app-title" content="Add Attachments" /> <title>Add Attachments to PDF - BentoPDF - @@ -113,7 +109,7 @@ > +
+ {{> footer-compact }} +
import '@phosphor-icons/web/regular'; + + + + - + @@ -469,7 +535,7 @@ "@type": "ListItem", "position": 2, "name": "Encrypt PDF", - "item": "https://www.bentopdf.com/encrypt-pdf" + "item": "https://www.bentopdf.com/protect-pdf" } ] } diff --git a/src/pages/psd-to-pdf.html b/src/pages/psd-to-pdf.html index 0d9db58f..c5cc5126 100644 --- a/src/pages/psd-to-pdf.html +++ b/src/pages/psd-to-pdf.html @@ -11,7 +11,7 @@ /> @@ -32,7 +32,7 @@ /> PSD to PDF - Convert Photoshop Files to PDF - BentoPDF - @@ -107,7 +103,7 @@ > + + +
+ + +
diff --git a/src/partials/footer-compact.html b/src/partials/footer-compact.html new file mode 100644 index 00000000..d159c85e --- /dev/null +++ b/src/partials/footer-compact.html @@ -0,0 +1,39 @@ +{{#unless simpleMode}} + +{{/unless}} diff --git a/src/partials/footer.html b/src/partials/footer.html index d01d5f04..1cd6ceb1 100644 --- a/src/partials/footer.html +++ b/src/partials/footer.html @@ -1,12 +1,12 @@ {{#if simpleMode}} {{> footer-simple}} {{else}} -