diff --git a/.github/workflows/build-and-publish.yml b/.github/workflows/build-and-publish.yml
index f62c22b9..c45724e8 100644
--- a/.github/workflows/build-and-publish.yml
+++ b/.github/workflows/build-and-publish.yml
@@ -29,6 +29,9 @@ jobs:
- name: Install dependencies
run: npm ci
+ - name: Run tests
+ run: npm run test:run
+
- name: Build distribution
run: npm run build
diff --git a/.github/workflows/docker-downloads-badge.yml b/.github/workflows/docker-downloads-badge.yml
new file mode 100644
index 00000000..fcf6b559
--- /dev/null
+++ b/.github/workflows/docker-downloads-badge.yml
@@ -0,0 +1,59 @@
+name: Update Docker Downloads Badge
+
+on:
+ schedule:
+ - cron: '17 4 * * 1'
+ workflow_dispatch:
+
+permissions:
+ contents: write
+
+concurrency:
+ group: docker-downloads-badge
+ cancel-in-progress: false
+
+jobs:
+ update:
+ if: github.repository == 'alam00000/bentopdf'
+ runs-on: ubuntu-latest
+ steps:
+ - name: Sum GHCR download counts
+ id: counts
+ run: |
+ set -euo pipefail
+ total=0
+ for pkg in bentopdf bentopdf-simple; do
+ raw=$(curl -sf --retry 3 --retry-delay 5 --max-time 30 \
+ "https://ghcr-badge.elias.eu.org/api/alam00000/bentopdf/$pkg" \
+ | jq -e -r '.downloadCountRaw')
+ case "$raw" in ''|*[!0-9]*) echo "::error::invalid count for $pkg: $raw"; exit 1 ;; esac
+ echo "$pkg: $raw"
+ total=$((total + raw))
+ done
+ if [ "$total" -le 0 ]; then echo "::error::total is zero"; exit 1; fi
+ message=$(awk -v n="$total" 'BEGIN{
+ if (n >= 1000000) printf "%.1fM", n/1000000;
+ else if (n >= 1000) printf "%.1fk", n/1000;
+ else printf "%d", n
+ }')
+ echo "total=$total" >> "$GITHUB_OUTPUT"
+ echo "message=$message" >> "$GITHUB_OUTPUT"
+ echo "combined: $total -> $message"
+
+ - name: Publish badge JSON to badges branch
+ env:
+ GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+ MESSAGE: ${{ steps.counts.outputs.message }}
+ run: |
+ set -euo pipefail
+ mkdir badges && cd badges
+ jq -n --arg message "$MESSAGE" \
+ '{schemaVersion: 1, label: "Docker Downloads", message: $message, color: "blue", namedLogo: "docker"}' \
+ > docker-downloads.json
+ cat docker-downloads.json
+ git init -q -b badges
+ git config user.name "github-actions[bot]"
+ git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
+ git add docker-downloads.json
+ git commit -qm "chore: update docker downloads badge ($MESSAGE)"
+ git push -qf "https://x-access-token:${GH_TOKEN}@github.com/${GITHUB_REPOSITORY}.git" badges
diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml
new file mode 100644
index 00000000..0acc8b6f
--- /dev/null
+++ b/.github/workflows/tests.yml
@@ -0,0 +1,36 @@
+name: Tests
+
+on:
+ push:
+ branches:
+ - 'main'
+ pull_request:
+ workflow_dispatch:
+
+concurrency:
+ group: tests-${{ github.ref }}
+ cancel-in-progress: true
+
+jobs:
+ test:
+ runs-on: ubuntu-latest
+ env:
+ HUSKY: 0
+ steps:
+ - name: Checkout code
+ uses: actions/checkout@v4
+
+ - name: Setup Node.js
+ uses: actions/setup-node@v4
+ with:
+ node-version: '20'
+ cache: 'npm'
+
+ - name: Install dependencies
+ run: npm ci
+
+ - name: Typecheck
+ run: npx tsc --noEmit
+
+ - name: Run tests
+ run: npm run test:run
diff --git a/.github/workflows/update-bentopdf-viewer.yml b/.github/workflows/update-bentopdf-viewer.yml
new file mode 100644
index 00000000..7f0136ca
--- /dev/null
+++ b/.github/workflows/update-bentopdf-viewer.yml
@@ -0,0 +1,198 @@
+name: Update PDF Engine and Viewer
+
+on:
+ workflow_dispatch:
+ schedule:
+ - cron: '17 4 * * *'
+
+permissions:
+ contents: write
+ pull-requests: write
+
+concurrency:
+ group: update-engine
+ cancel-in-progress: false
+
+env:
+ FORK_URL: https://github.com/alam00000/bentopdf-pdfium-viewer
+ HUSKY: 0
+
+jobs:
+ update:
+ runs-on: ubuntu-latest
+ if: github.repository == 'alam00000/bentopdf'
+ steps:
+ - name: Checkout repo
+ uses: actions/checkout@v4
+
+ - name: Read current upstream marker
+ id: current
+ run: |
+ if [ -f vendor/bentopdf-viewer/.upstream-version ]; then
+ echo "version=$(cat vendor/bentopdf-viewer/.upstream-version)" >> "$GITHUB_OUTPUT"
+ else
+ echo "version=" >> "$GITHUB_OUTPUT"
+ fi
+
+ - name: Read fork HEAD
+ id: upstream
+ run: echo "version=$(git ls-remote "$FORK_URL" refs/heads/main | cut -c1-12)" >> "$GITHUB_OUTPUT"
+
+ - name: Should update?
+ id: gate
+ run: |
+ if [ "${{ steps.upstream.outputs.version }}" = "${{ steps.current.outputs.version }}" ]; then
+ echo "run=false" >> "$GITHUB_OUTPUT"
+ echo "Already on ${{ steps.current.outputs.version }}."
+ else
+ echo "run=true" >> "$GITHUB_OUTPUT"
+ echo "Updating ${{ steps.current.outputs.version }} -> ${{ steps.upstream.outputs.version }}"
+ fi
+
+ - name: Setup Node
+ if: steps.gate.outputs.run == 'true'
+ uses: actions/setup-node@v4
+ with:
+ node-version: 22
+ cache: npm
+
+ - name: Enable corepack (pnpm)
+ if: steps.gate.outputs.run == 'true'
+ run: corepack enable
+
+ - name: Clone the viewer fork
+ if: steps.gate.outputs.run == 'true'
+ run: git clone --depth 1 --branch main "$FORK_URL" ./_fork
+
+ - name: Require a published engine
+ if: steps.gate.outputs.run == 'true'
+ run: |
+ test -s ./_fork/packages/pdfium/src/vendor/pdfium.wasm
+ test -s ./_fork/packages/pdfium/src/vendor/pdfium.js
+
+ - name: Pack the engine
+ if: steps.gate.outputs.run == 'true'
+ id: engine
+ run: |
+ set -euo pipefail
+ SHA=${{ steps.upstream.outputs.version }}
+ VENDOR=./_fork/packages/pdfium/src/vendor
+ STAGE=$(mktemp -d)/package
+ mkdir -p "$STAGE"
+ cp "$VENDOR/pdfium.js" "$STAGE/editcore.js"
+ cp "$VENDOR/pdfium.wasm" "$STAGE/editcore.wasm"
+ cat > "$STAGE/package.json" <> "$GITHUB_OUTPUT"
+
+ - name: Build the viewer
+ if: steps.gate.outputs.run == 'true'
+ working-directory: ./_fork
+ run: |
+ pnpm install --no-frozen-lockfile
+ pnpm run build:snippet
+
+ - name: Pack the viewer without a second engine
+ if: steps.gate.outputs.run == 'true'
+ id: viewer
+ run: |
+ set -euo pipefail
+ SHA=${{ steps.upstream.outputs.version }}
+ WORK=$(mktemp -d)
+ (cd ./_fork && npm pack ./viewers/snippet --pack-destination "$WORK" >/dev/null)
+ TMP=$(mktemp -d)
+ tar -xzf "$WORK"/*.tgz -C "$TMP"
+ rm -f "$TMP/package/dist/pdfium.wasm"
+ PKG="$TMP/package/package.json" node - <<'NODE'
+ const fs = require('fs');
+ const p = process.env.PKG;
+ const pkg = JSON.parse(fs.readFileSync(p, 'utf8'));
+ pkg.name = 'bentopdf-viewer';
+ pkg.dependencies = pkg.dependencies || {};
+ // The rollup bundle inlines every @embedpdf/* package, so these
+ // declared deps would only pull unused copies from upstream npm.
+ for (const k of Object.keys(pkg.dependencies)) {
+ if (k.startsWith('@embedpdf/')) delete pkg.dependencies[k];
+ if (k === 'preact') pkg.dependencies[k] = '^10.17.0';
+ }
+ fs.writeFileSync(p, JSON.stringify(pkg, null, 2) + '\n');
+ NODE
+ mkdir -p vendor/bentopdf-viewer
+ OUT="vendor/bentopdf-viewer/bentopdf-viewer-$SHA.tgz"
+ tar -czf "$OUT" -C "$TMP" package
+ find vendor/bentopdf-viewer -maxdepth 1 -name 'bentopdf-viewer-*.tgz' ! -name "$(basename "$OUT")" -delete
+ echo "tarball=$OUT" >> "$GITHUB_OUTPUT"
+
+ - name: Point dependencies at the new tarballs
+ if: steps.gate.outputs.run == 'true'
+ run: |
+ node -e "
+ const fs = require('fs');
+ const pkg = JSON.parse(fs.readFileSync('package.json', 'utf8'));
+ pkg.dependencies['bentopdf-pdfium'] = 'file:' + process.argv[1];
+ pkg.dependencies['bentopdf-viewer'] = 'file:' + process.argv[2];
+ fs.writeFileSync('package.json', JSON.stringify(pkg, null, 2) + '\n');
+ " "${{ steps.engine.outputs.tarball }}" "${{ steps.viewer.outputs.tarball }}"
+
+ - name: Cleanup upstream clone
+ if: steps.gate.outputs.run == 'true'
+ run: rm -rf ./_fork
+
+ - name: Install and verify one shared engine
+ if: steps.gate.outputs.run == 'true'
+ run: |
+ set -euo pipefail
+ npm install
+ test -s node_modules/bentopdf-pdfium/editcore.wasm
+ if find node_modules/bentopdf-viewer -name '*.wasm' | grep -q .; then
+ echo "::error::viewer package still ships a wasm; the engine would be duplicated"
+ exit 1
+ fi
+ if ! grep -q "bentopdf-pdfium/editcore.wasm" src/js/logic/edit-pdf-page.ts; then
+ echo "::error::the PDF editor no longer points at the shared engine"
+ exit 1
+ fi
+ echo "engine sha256: $(sha256sum node_modules/bentopdf-pdfium/editcore.wasm | cut -c1-16)"
+
+ - name: Typecheck and test
+ if: steps.gate.outputs.run == 'true'
+ run: |
+ npx tsc --noEmit
+ npm run test:run
+
+ - name: Write upstream marker
+ if: steps.gate.outputs.run == 'true'
+ run: |
+ echo "${{ steps.upstream.outputs.version }}" > vendor/bentopdf-viewer/.upstream-version
+ echo "${{ steps.upstream.outputs.version }}" > vendor/bentopdf-pdfium/.upstream-version
+
+ - name: Create Pull Request
+ if: steps.gate.outputs.run == 'true'
+ uses: peter-evans/create-pull-request@v6
+ with:
+ commit-message: 'build(deps): bump PDF engine and viewer to ${{ steps.upstream.outputs.version }}'
+ title: 'build(deps): bump PDF engine and viewer to ${{ steps.upstream.outputs.version }}'
+ body: |
+ Both vendored packages built from one clone of the viewer fork at
+ `${{ steps.upstream.outputs.version }}`, so the text editor and the
+ PDF editor run the same engine.
+
+ - `bentopdf-pdfium` — the engine wasm, packed from the fork's published `src/vendor`
+ - `bentopdf-viewer` — the snippet bundle, packed **without** `dist/pdfium.wasm`
+
+ The viewer loads the shared engine through `EmbedPDF.init({ wasmUrl })`.
+ CI verified a single wasm on disk, typecheck, and the test suite.
+ branch: chore/update-pdf-engine
+ delete-branch: true
diff --git a/.github/workflows/update-embedpdf-snippet.yml b/.github/workflows/update-embedpdf-snippet.yml
deleted file mode 100644
index 9e91d4bb..00000000
--- a/.github/workflows/update-embedpdf-snippet.yml
+++ /dev/null
@@ -1,141 +0,0 @@
-name: Update EmbedPDF Snippet
-
-on:
- workflow_dispatch:
-
-permissions:
- contents: write
- pull-requests: write
-
-jobs:
- update-snippet:
- runs-on: ubuntu-latest
- steps:
- - name: Checkout repo
- uses: actions/checkout@v4
-
- - name: Read current upstream version marker
- id: current-version
- run: |
- if [ -f vendor/embedpdf/.upstream-version ]; then
- CUR=$(cat vendor/embedpdf/.upstream-version)
- else
- CUR=""
- fi
- echo "version=$CUR" >> "$GITHUB_OUTPUT"
-
- - name: Read latest upstream version (@embedpdf/core)
- id: upstream-version
- run: |
- LATEST=$(npm view @embedpdf/core version)
- echo "version=$LATEST" >> "$GITHUB_OUTPUT"
-
- - name: Should update?
- id: gate
- run: |
- if [ "${{ steps.upstream-version.outputs.version }}" = "${{ steps.current-version.outputs.version }}" ]; then
- echo "run=false" >> "$GITHUB_OUTPUT"
- echo "No upstream version change detected."
- else
- echo "run=true" >> "$GITHUB_OUTPUT"
- echo "Updating from '${{ steps.current-version.outputs.version }}' to '${{ steps.upstream-version.outputs.version }}'"
- fi
-
- - name: Enable corepack (pnpm)
- if: steps.gate.outputs.run == 'true'
- run: corepack enable
-
- - name: Prepare workspace
- if: steps.gate.outputs.run == 'true'
- run: |
- mkdir -p vendor/embedpdf
- npm config set cache ./\.npm-cache
-
- - name: Clone upstream embed-pdf-viewer
- if: steps.gate.outputs.run == 'true'
- run: git clone --depth 1 --branch main https://github.com/embedpdf/embed-pdf-viewer ./_upstream/embed-pdf-viewer
-
- - name: Setup Node
- if: steps.gate.outputs.run == 'true'
- uses: actions/setup-node@v4
- with:
- node-version: 20
- cache: npm
-
- - name: Install upstream deps
- if: steps.gate.outputs.run == 'true'
- working-directory: ./_upstream/embed-pdf-viewer
- run: pnpm install --no-frozen-lockfile
-
- - name: Build snippet
- if: steps.gate.outputs.run == 'true'
- working-directory: ./_upstream/embed-pdf-viewer
- run: pnpm run build:snippet
-
- - name: Pack snippet tarball
- if: steps.gate.outputs.run == 'true'
- working-directory: ./_upstream/embed-pdf-viewer
- run: |
- npm pack ./viewers/snippet --pack-destination ../../vendor/embedpdf
- ls -l ../../vendor/embedpdf
-
- - name: Sanitize tarball (rename pkg and pin deps)
- if: steps.gate.outputs.run == 'true'
- env:
- UPSTREAM_VERSION: ${{ steps.upstream-version.outputs.version }}
- run: |
- TARBALL=$(ls vendor/embedpdf/*.tgz | sort | tail -n1)
- TMP=$(mktemp -d)
- tar -xzf "$TARBALL" -C "$TMP"
- PKG="$TMP/package/package.json"
- PKG="$PKG" node - <<'NODE'
- const fs = require("fs");
- const path = process.env.PKG;
- const ver = process.env.UPSTREAM_VERSION || "1.4.1";
- const pkg = JSON.parse(fs.readFileSync(path, "utf8"));
- pkg.name = "embedpdf-snippet";
- pkg.dependencies = pkg.dependencies || {};
- for (const k of Object.keys(pkg.dependencies)) {
- if (k.startsWith("@embedpdf/")) pkg.dependencies[k] = `^${ver}`;
- if (k === "preact") pkg.dependencies[k] = "^10.17.0";
- }
- fs.writeFileSync(path, JSON.stringify(pkg, null, 2) + "\n");
- NODE
- NEW=vendor/embedpdf/embedpdf-snippet-${UPSTREAM_VERSION}.tgz
- tar -czf "$NEW" -C "$TMP" package
- # Remove any older snippet tarballs, keep only the new one
- find vendor/embedpdf -maxdepth 1 -name 'embedpdf-snippet-*.tgz' ! -name "$(basename "$NEW")" -delete
- rm -rf "$TMP"
- ls -l vendor/embedpdf
-
- - name: Update package.json dependency path
- if: steps.gate.outputs.run == 'true'
- run: |
- TARBALL=$(ls vendor/embedpdf/embedpdf-snippet-*.tgz | sort | tail -n1)
- node -e "const fs=require('fs');const pkg=require('./package.json');const tar=process.argv[1];pkg.dependencies['embedpdf-snippet']='file:'+tar;fs.writeFileSync('package.json',JSON.stringify(pkg,null,2)+'\n');" "$TARBALL"
-
- - name: Refresh lockfile
- if: steps.gate.outputs.run == 'true'
- run: npm install --package-lock-only --ignore-scripts
-
- - name: Write upstream version marker
- if: steps.gate.outputs.run == 'true'
- run: |
- echo "${{ steps.upstream-version.outputs.version }}" > vendor/embedpdf/.upstream-version
-
- - name: Cleanup upstream clone
- if: steps.gate.outputs.run == 'true'
- run: rm -rf ./_upstream
-
- - name: Create Pull Request
- if: steps.gate.outputs.run == 'true'
- uses: peter-evans/create-pull-request@v6
- with:
- commit-message: "build(deps): bump embedpdf-snippet from ${{ steps.current-version.outputs.version }} to ${{ steps.upstream-version.outputs.version }}"
- title: "build(deps): bump embedpdf-snippet from ${{ steps.current-version.outputs.version }} to ${{ steps.upstream-version.outputs.version }}"
- body: |
- - Build snippet from upstream embed-pdf-viewer via `npm run build:snippet`
- - Pack tarball into `vendor/embedpdf/` and point dependency to it
- - Refresh package-lock.json
- branch: chore/update-embedpdf-snippet
- delete-branch: true
diff --git a/.gitignore b/.gitignore
index 60fe8a18..d48b56e0 100644
--- a/.gitignore
+++ b/.gitignore
@@ -46,8 +46,11 @@ codeql-results.csv
libreoffice-wasm-package
# helm chart
-bentopdf-*.tgz
+/bentopdf-*.tgz
# test
dist-test
-test
\ No newline at end of file
+test
+# Sibling checkouts of the engine/viewer forks
+bentopdf-pdfium-viewer/
+bentopdf-pymupdf-wasm/
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index c5ffb7bc..bb1e70eb 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -81,7 +81,7 @@ When submitting code contributions, please use our [pull request template](.gith
```bash
git clone https://github.com/alam00000/bentopdf.git
- cd bento-pdf
+ cd bentopdf
npm install
```
diff --git a/Dockerfile b/Dockerfile
index a6eefff6..245e52a6 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -19,6 +19,8 @@ COPY . .
# Pass SIMPLE_MODE environment variable if provided
ARG SIMPLE_MODE=false
ENV SIMPLE_MODE=$SIMPLE_MODE
+ARG DISABLE_GITHUB_STARS=false
+ENV DISABLE_GITHUB_STARS=$DISABLE_GITHUB_STARS
ARG COMPRESSION_MODE=all
ENV COMPRESSION_MODE=$COMPRESSION_MODE
diff --git a/Dockerfile.nonroot b/Dockerfile.nonroot
index 38f2bb82..aa16f550 100644
--- a/Dockerfile.nonroot
+++ b/Dockerfile.nonroot
@@ -19,6 +19,8 @@ COPY . .
ARG SIMPLE_MODE=false
ENV SIMPLE_MODE=$SIMPLE_MODE
+ARG DISABLE_GITHUB_STARS=false
+ENV DISABLE_GITHUB_STARS=$DISABLE_GITHUB_STARS
ARG COMPRESSION_MODE=all
ENV COMPRESSION_MODE=$COMPRESSION_MODE
diff --git a/README.md b/README.md
index 0c81ee36..d24cf108 100644
--- a/README.md
+++ b/README.md
@@ -6,9 +6,9 @@
-**BentoPDF** is a powerful, privacy-first, client-side PDF toolkit that is self hostable and allows you to manipulate, edit, merge, and process PDF files directly in your browser. No server-side processing is required, ensuring your files remain secure and private.
+**BentoPDF** is a powerful, privacy-first, client-side PDF toolkit that is self-hostable and allows you to manipulate, edit, merge, and process PDF files directly in your browser. No server-side processing is required, ensuring your files remain secure and private.
-[](https://github.com/alam00000/bentopdf/pkgs/container/bentopdf) [](https://ko-fi.com/alio01) 
+[](https://github.com/alam00000/bentopdf/pkgs/container/bentopdf-simple) [](https://ko-fi.com/alio01) 
[](https://github.com/sponsors/alam00000)

@@ -41,6 +41,7 @@
- [Commercial Build](#-commercial-build)
- [Custom Branding](#-custom-branding)
- [Disabling Specific Tools](#-disabling-specific-tools)
+ - [Disabling the GitHub Star Counter](#-disabling-the-github-star-counter)
- [WASM Configuration](#wasm-configuration)
- [Air-Gapped / Offline Deployment](#air-gapped--offline-deployment)
- [Security Features](#-security-features)
@@ -112,7 +113,7 @@ BentoPDF does **not** bundle AGPL-licensed processing libraries in its source co
## ⭐ Stargazers over time
-[](https://star-history.com/#alam00000/bentopdf&Date)
+[](https://github.com/alam00000/bentopdf/stargazers)
---
@@ -131,7 +132,7 @@ We're incredibly grateful to all our sponsors and supporters who help keep Bento
## ✨ Why BentoPDF?
- **Privacy First**: All processing happens in your browser. Your files are never uploaded to a server, guaranteeing 100% privacy.
-- **No Limits**: Manipulate as many files as you want, as often you want. There are no restrictions or upload limits.
+- **No Limits**: Manipulate as many files as you want, as often as you want. There are no restrictions or upload limits.
- **High Performance**: Built with modern web technologies, BentoPDF is fast and efficient, handling even large PDF files with ease.
- **Completely Free**: BentoPDF is a free and open-source tool for everyone.
@@ -143,31 +144,31 @@ BentoPDF offers a comprehensive suite of tools to handle all your PDF needs.
### Organize & Manage PDFs
-| Tool Name | Description |
-| :--------------------------- | :------------------------------------------------------------------------------------------------------ |
-| **Merge PDFs** | Combine multiple PDF files into one. Preserves Bookmarks. |
-| **Split PDFs** | Extract specific pages or divide a document into smaller files. |
-| **Organize Pages** | Reorder, duplicate, or delete pages with a simple drag-and-drop interface. |
-| **Extract Pages** | Save a specific range of pages as a new PDF. |
-| **Delete Pages** | Remove unwanted pages from your document. |
-| **Rotate PDF** | Rotate individual or all pages in a document. |
-| **Rotate by Custom Degrees** | Rotate pages by any custom angle. |
-| **N-Up PDF** | Combine multiple pages onto a single page. |
-| **View PDF** | A powerful, integrated PDF viewer. |
-| **Alternate & Mix Pages** | Merge pages by alternating pages from each PDF. Preserves Bookmarks. |
-| **Posterize PDF** | Split a PDF into multiple smaller pages for print. |
-| **PDF Multi Tool** | Merge, Split, Organize, Delete, Rotate, Add Blank Pages, Extract and Duplicate in an unified interface. |
-| **PDF Booklet** | Rearrange pages for double-sided booklet printing. Fold and staple to create a booklet. |
-| **Add Attachments** | Embed one or more files into your PDF. |
-| **Extract Attachments** | Extract all embedded files from PDF(s) as a ZIP. |
-| **Edit Attachments** | View or remove attachments in your PDF. |
-| **Divide Pages** | Divide pages horizontally or vertically. |
-| **Combine to Single Page** | Stitch all pages into one continuous scroll. |
-| **Add Blank Page** | Insert an empty page anywhere in your PDF. |
-| **Reverse Pages** | Flip the order of all pages in your document. |
-| **View Metadata** | Inspect the hidden properties of your PDF. |
-| **PDFs to ZIP** | Package multiple PDF files into a ZIP archive. |
-| **Compare PDFs** | Compare two PDFs side by side. |
+| Tool Name | Description |
+| :--------------------------- | :----------------------------------------------------------------------------------------------------- |
+| **Merge PDFs** | Combine multiple PDF files into one. Preserves Bookmarks. |
+| **Split PDFs** | Extract specific pages or divide a document into smaller files. |
+| **Organize Pages** | Reorder, duplicate, or delete pages with a simple drag-and-drop interface. |
+| **Extract Pages** | Save a specific range of pages as a new PDF. |
+| **Delete Pages** | Remove unwanted pages from your document. |
+| **Rotate PDF** | Rotate individual or all pages in a document. |
+| **Rotate by Custom Degrees** | Rotate pages by any custom angle. |
+| **N-Up PDF** | Combine multiple pages onto a single page. |
+| **View PDF** | A powerful, integrated PDF viewer. |
+| **Alternate & Mix Pages** | Merge pages by alternating pages from each PDF. Preserves Bookmarks. |
+| **Posterize PDF** | Split a PDF into multiple smaller pages for print. |
+| **PDF Multi Tool** | Merge, Split, Organize, Delete, Rotate, Add Blank Pages, Extract and Duplicate in a unified interface. |
+| **PDF Booklet** | Rearrange pages for double-sided booklet printing. Fold and staple to create a booklet. |
+| **Add Attachments** | Embed one or more files into your PDF. |
+| **Extract Attachments** | Extract all embedded files from PDF(s) as a ZIP. |
+| **Edit Attachments** | View or remove attachments in your PDF. |
+| **Divide Pages** | Divide pages horizontally or vertically. |
+| **Combine to Single Page** | Stitch all pages into one continuous scroll. |
+| **Add Blank Page** | Insert an empty page anywhere in your PDF. |
+| **Reverse Pages** | Flip the order of all pages in your document. |
+| **View Metadata** | Inspect the hidden properties of your PDF. |
+| **PDFs to ZIP** | Package multiple PDF files into a ZIP archive. |
+| **Compare PDFs** | Compare two PDFs side by side. |
### Edit & Modify PDFs
@@ -332,7 +333,7 @@ Open your browser at: http://localhost:3000
> - **Self-Hosted build** — `ghcr.io/alam00000/bentopdf-simple:latest`. Every PDF tool the public site has, **without** the BentoPDF marketing (no hero, FAQ, testimonials, footer). Use this for internal/team/organization deployments. It is **not** a feature-reduced lite version.
> - **Commercial build** — `ghcr.io/alam00000/bentopdf:latest`. The full marketing site, used by bentopdf.com itself and by commercial license holders running public-facing deployments. Includes hero, FAQ, testimonials, and footer.
>
-> If in doubt: pull the Self Hosted build.
+> If in doubt: pull the Self-Hosted build.
1. Our Commitment to Privacy
@@ -127,44 +125,17 @@
-
3. Information We May Collect (Non-Personal Data)
+
3. No Tracking or Analytics
- To improve our website and services, we may collect anonymous,
- non-personally identifiable information. This type of data helps us
- understand how users interact with our site, which tools are most
- popular, and how we can improve the user experience. This includes:
-
-
- -
- Usage Analytics: Anonymized data such as which
- tools are used, how often they are used, and which features are
- accessed. This is aggregated and cannot be tied back to an
- individual user or document.
-
- -
- Performance Data: Anonymized error reports or
- performance metrics to help us identify and fix bugs. This data
- contains no personal information or file content.
-
-
-
- We use privacy-respecting analytics platforms for this purpose.
- Specifically, we use
- Simple Analytics
- to track anonymous visit counts. This means we can see how many
- users visit our site, but
+ BentoPDF does not track you at all. We do not use cookies, analytics
+ platforms, error-reporting services, or any tracking scripts, not
+ even on the live website at bentopdf.com. We do not collect usage
+ data, visit counts, performance metrics, or any other information
+ about you or your session, anonymized or otherwise.
we never collect personal information or identify individual
- users. Simple Analytics is fully GDPR-compliant and respects user
- privacy. We do not use tracking cookies for advertising or
- cross-site profiling.
+ >We never know who you are, how many people use BentoPDF, or what
+ you do with it.
4. Third-Party Libraries
diff --git a/public/CHANGELOG.md b/public/CHANGELOG.md
index 2c599fd1..2078fb89 100644
--- a/public/CHANGELOG.md
+++ b/public/CHANGELOG.md
@@ -19,7 +19,7 @@ Generated by [`auto-changelog`](https://github.com/CookPete/auto-changelog).
- Feature/reverse multi pdfs [`#58`](https://github.com/alam00000/bentopdf/pull/58)
- added description to Unraid XML [`#65`](https://github.com/alam00000/bentopdf/pull/65)
- Added hamburger Navbar [`#62`](https://github.com/alam00000/bentopdf/pull/62)
-- feat(versioning): Added version management sysytem [`#52`](https://github.com/alam00000/bentopdf/pull/52)
+- feat(versioning): Added version management system [`#52`](https://github.com/alam00000/bentopdf/pull/52)
- Fix#20 : fixed the tesseract issue [`#60`](https://github.com/alam00000/bentopdf/pull/60)
- feat: add search bar keyboard shortcut [`#49`](https://github.com/alam00000/bentopdf/pull/49)
- feat(pdf-tools): add attachments feature to embed files in PDFs [`#53`](https://github.com/alam00000/bentopdf/pull/53)
@@ -27,7 +27,7 @@ Generated by [`auto-changelog`](https://github.com/CookPete/auto-changelog).
- feat: add support for multiple blank pages insertion [`#42`](https://github.com/alam00000/bentopdf/pull/42)
- Multiupload handle encrypted pdf [`#43`](https://github.com/alam00000/bentopdf/pull/43)
- feat:Setup Prettier for code formatting [`#30`](https://github.com/alam00000/bentopdf/pull/30)
-- created png as unraid does not supprot svg [`#24`](https://github.com/alam00000/bentopdf/pull/24)
+- created png as unraid does not support svg [`#24`](https://github.com/alam00000/bentopdf/pull/24)
- adding unraid template [`#23`](https://github.com/alam00000/bentopdf/pull/23)
- feat(pdf-tools): add alternate merge tool for mixing pdf pages [`#21`](https://github.com/alam00000/bentopdf/pull/21)
- feat: add remove blank pages tool with sensitivity control [`#19`](https://github.com/alam00000/bentopdf/pull/19)
diff --git a/public/locales/ar/common.json b/public/locales/ar/common.json
index 0b2872a1..b0887db4 100644
--- a/public/locales/ar/common.json
+++ b/public/locales/ar/common.json
@@ -216,7 +216,7 @@
},
"analytics": {
"question": "هل تستخدمون ملفات تعريف الارتباط أو التحليلات لتتبعي؟",
- "answer": "نحن نهتم بخصوصيتك. BentoPDF لا يتتبع المعلومات الشخصية. نستخدم Simple Analytics فقط لرؤية عدد الزيارات المجهولة. هذا يعني أننا نعرف عدد المستخدمين الذين يزورون موقعنا، لكننا لا نعرف أبدًا من أنت. Simple Analytics متوافق تمامًا مع GDPR ويحترم خصوصيتك."
+ "answer": "لا. لا يتتبعك BentoPDF على الإطلاق. لا نستخدم ملفات تعريف الارتباط أو أدوات التحليلات أو أي نصوص تتبع، ولا حتى على الموقع المباشر bentopdf.com. لا نعرف أبدًا من أنت أو ماذا تفعل بالأدوات."
}
},
"testimonials": {
diff --git a/public/locales/ar/tools.json b/public/locales/ar/tools.json
index e41f8d81..36ce7715 100644
--- a/public/locales/ar/tools.json
+++ b/public/locales/ar/tools.json
@@ -55,6 +55,122 @@
"name": "محرر PDF",
"subtitle": "تعليق، تمييز، تنقيح، تعليقات، إضافة أشكال/صور، بحث وعرض ملفات PDF."
},
+ "editPdfText": {
+ "name": "تحرير نص PDF",
+ "subtitle": "انقر على أي فقرة وحرّرها في مكانها مع إعادة تدفق فوري للنص والخطوط والأنماط.",
+ "saved": "تم حفظ ملف PDF بنجاح! ({{size}} كيلوبايت)",
+ "failedToLoad": "تعذّر تحميل محرّر نصوص PDF.",
+ "howToUpload": "رفع ملف PDF",
+ "howToClick": "انقر على فقرة",
+ "howToRestyle": "غيّر تنسيق أي شيء",
+ "relatedPdfEditor": "التعليق على ملفات PDF وتمييزها",
+ "relatedSignPdf": "ارسم توقيعك أو اكتبه أو ارفعه",
+ "relatedWatermark": "اطبع نصًا أو صورًا",
+ "relatedStamps": "ضع أختامًا على الصفحات",
+ "relatedCompress": "تقليل حجم الملف",
+ "relatedOrganizeName": "تنظيم PDF",
+ "relatedOrganize": "إعادة ترتيب الصفحات وإدارتها",
+ "open": "فتح",
+ "export": "تصدير",
+ "filterAll": "الكل",
+ "filterText": "نص",
+ "filterImages": "صور",
+ "filterShapes": "أشكال",
+ "addText": "إضافة نص",
+ "langAuto": "تلقائي",
+ "change": "تغيير",
+ "ignore": "تجاهل",
+ "next": "التالي",
+ "replace": "استبدال",
+ "replaceAll": "استبدال الكل",
+ "emptyState": "افتح ملف PDF لتحرير نصه وصوره وأشكاله.",
+ "choosePdf": "اختر ملف PDF…",
+ "font": "الخط",
+ "systemFonts": "خطوط النظام",
+ "color": "اللون",
+ "alignment": "المحاذاة",
+ "style": "النمط",
+ "lists": "القوائم",
+ "outline": "المخطط",
+ "spacing": "التباعد",
+ "direction": "الاتجاه",
+ "dirAuto": "تلقائي",
+ "pathStroke": "حد المسار",
+ "tipBackToUpload": "العودة إلى الرفع",
+ "tipOpenAnotherPdf": "فتح ملف PDF آخر",
+ "tipExportS": "تصدير (⌘S)",
+ "tipUndoZ": "تراجع (⌘Z)",
+ "tipRedoZ": "إعادة (⇧⌘Z)",
+ "tipFileDetails": "تفاصيل الملف",
+ "tipEditExistingContent": "تحرير المحتوى الحالي",
+ "tipWhatTheEditToolTouchesNarrowTheScopeOnBus": "ما تتأثر به أداة التحرير — ضيّق النطاق في الصفحات المزدحمة",
+ "tipClickThePageToAddATextBox": "انقر على الصفحة لإضافة مربع نص",
+ "tipSpellCheckLanguage": "لغة التدقيق الإملائي",
+ "tipFindAndReplace": "بحث واستبدال",
+ "tipAddAnImage": "إضافة صورة",
+ "tipCopyThisPageSTextInReadingOrder": "نسخ نص هذه الصفحة بترتيب القراءة",
+ "tipCheckTheSpellingOfThisPage": "تدقيق إملاء هذه الصفحة",
+ "tipShowOrHideTheFormatPanel": "إظهار لوحة التنسيق أو إخفاؤها",
+ "phReplaceWith": "استبدال بـ",
+ "tipAddToYourDictionary": "إضافة إلى القاموس",
+ "tipClose": "إغلاق",
+ "phFind": "بحث",
+ "tipPreviousMatch": "التطابق السابق",
+ "tipNextMatch": "التطابق التالي",
+ "tipRotateLeft90": "تدوير 90° لليسار",
+ "tipRotateRight90": "تدوير 90° لليمين",
+ "tipFlipHorizontal": "قلب أفقي",
+ "tipFlipVertical": "قلب رأسي",
+ "tipReplaceThisImageKeepingItsPlacement": "استبدال هذه الصورة مع الحفاظ على موضعها",
+ "tipAlternateTextForAccessibilityTaggedPdfs": "نص بديل لإمكانية الوصول (ملفات PDF الموسومة)",
+ "tipBringToFront": "إحضار إلى الأمام",
+ "tipSendToBack": "إرسال إلى الخلف",
+ "tipDuplicate": "تكرار",
+ "tipDelete": "حذف",
+ "tipAlignLeftEdges": "محاذاة الحواف اليسرى",
+ "tipAlignHorizontalCenters": "توسيط أفقي",
+ "tipAlignRightEdges": "محاذاة الحواف اليمنى",
+ "tipAlignTops": "محاذاة الحواف العلوية",
+ "tipAlignVerticalCenters": "توسيط رأسي",
+ "tipAlignBottoms": "محاذاة الحواف السفلية",
+ "tipDistributeHorizontally": "توزيع أفقي",
+ "tipDistributeVertically": "توزيع رأسي",
+ "tipFontSize": "حجم الخط",
+ "tipListFontsInstalledOnThisDeviceChromium": "عرض الخطوط المثبتة على هذا الجهاز (Chromium)",
+ "tipCustomTextColor": "لون نص مخصص",
+ "tipAlignLeft": "محاذاة لليسار",
+ "tipAlignCenter": "توسيط",
+ "tipAlignRight": "محاذاة لليمين",
+ "tipJustify": "ضبط",
+ "tipBold": "عريض",
+ "tipItalic": "مائل",
+ "tipUnderline": "تسطير",
+ "tipStrikethrough": "يتوسطه خط",
+ "tipSuperscript": "حرف مرتفع",
+ "tipSubscript": "حرف منخفض",
+ "tipToggleBulletedItem": "تبديل عنصر نقطي",
+ "tipToggleNumberedItem": "تبديل عنصر مرقّم",
+ "tipNumberedListMarkerStyle": "نمط علامة القوائم المرقّمة",
+ "tipDecreaseListIndentTab": "تقليل المسافة البادئة (⇧Tab)",
+ "tipIncreaseListIndentTab": "زيادة المسافة البادئة (Tab)",
+ "tipTextOutlineColor": "لون حد النص",
+ "tipOutlineWidth0None": "عرض الحد (0 = بلا)",
+ "tipFromContent": "من المحتوى",
+ "tipLeftToRight": "من اليسار إلى اليمين",
+ "tipRightToLeft": "من اليمين إلى اليسار",
+ "tipStrokeColor": "لون الحد",
+ "tipStrokeWidth": "عرض الحد",
+ "tipZoomPresets": "إعدادات التكبير المسبقة",
+ "tipZoomOut": "تصغير",
+ "tipZoomIn": "تكبير",
+ "tipFitPage": "ملاءمة الصفحة",
+ "tipPreviousPage": "الصفحة السابقة",
+ "tipTypeAPageNumberAndPressEnter": "اكتب رقم الصفحة ثم اضغط Enter",
+ "tipNextPage": "الصفحة التالية",
+ "tipDocumentProperties": "خصائص المستند",
+ "tipHideBar": "إخفاء الشريط",
+ "tipShowBar": "إظهار الشريط"
+ },
"jpgToPdf": {
"name": "JPG إلى PDF",
"subtitle": "إنشاء PDF من صور JPG وJPEG وJPEG2000 (JP2/JPX)."
@@ -514,6 +630,51 @@
"retainPageLabels": "الاحتفاظ بتسميات الصفحات الأصلية لكل ملف",
"retainPageLabelsHint": "غير مفعّل (افتراضي): يستخدم ملف PDF المدمج ترقيمًا طبيعيًا من 1 إلى N. مفعّل: يتم الاحتفاظ بتسميات الصفحات الأصلية لكل ملف (قد ينتج عن ذلك تسميات مكررة)."
},
+ "duplexCollate": {
+ "name": "ترتيب المسح على الوجهين",
+ "subtitle": "أعد ترتيب ملف PDF ممسوح في كتلتين (كل الوجوه الأمامية أولًا، ثم كل الوجوه الخلفية) إلى ترتيب أمامي/خلفي صحيح.",
+ "totalPagesLabel": "إجمالي الصفحات:",
+ "splitAfterPageLabel": "التقسيم بعد الصفحة",
+ "splitAfterPagePlaceholder": "مثال: 50",
+ "backBlockOrderLabel": "ترتيب كتلة الوجه الخلفي",
+ "backBlockReverseOption": "عكس كتلة الوجه الخلفي (شائع عند قلب الرزمة)",
+ "backBlockKeepOption": "إبقاء كتلة الوجه الخلفي كما هي",
+ "autoSplitButton": "اكتشاف نقطة التقسيم تلقائيًا (المنتصف)",
+ "exportGroupedLabel": "تصدير ملفات مجمّعة حسب طول المستند الأصلي",
+ "pagesPerDocumentLabel": "عدد الصفحات لكل مستند أصلي",
+ "groupedHelp": "عند التفعيل، يكون الناتج ملف ZIP يحتوي على ملف PDF لكل مستند مجمّع.",
+ "collateButton": "ترتيب ملف PDF",
+ "invalidFileTitle": "ملف غير صالح",
+ "invalidFileMessage": "يرجى اختيار ملف PDF.",
+ "loadingPdf": "جارٍ تحميل ملف PDF...",
+ "loadErrorMessage": "تعذّر تحميل ملف PDF.",
+ "fileMeta": "{{size}} - {{count}} صفحة",
+ "oddPageWarning": "عدد الصفحات فردي ({{count}}). يجب أن يكون عدد صفحات المسح على الوجهين زوجيًا؛ قد تكون هناك صفحة ناقصة أو زائدة.",
+ "previewSummary": "كتلة الوجه الأمامي: {{frontCount}} صفحة، كتلة الوجه الخلفي: {{backCount}} صفحة. الناتج: {{outputCount}} صفحة بترتيب أمامي/خلفي.",
+ "previewMismatchWarning": "طول كتلتي الوجه الأمامي والخلفي مختلف ({{frontCount}} أمامية مقابل {{backCount}} خلفية). ستُضاف الصفحات الزائدة ({{extraCount}}) من الكتلة الأطول في النهاية دون مقابل.",
+ "missingFileTitle": "ملف مفقود",
+ "missingFileMessage": "يرجى رفع ملف PDF يحتوي على صفحتين على الأقل قبل المعالجة.",
+ "invalidSplitTitle": "نقطة تقسيم غير صالحة",
+ "invalidSplitMessage": "يجب أن تكون نقطة التقسيم بين الصفحة 1 والصفحة {{maxSplit}}.",
+ "unevenConfirmTitle": "كتل غير متساوية",
+ "unevenConfirmMessage": "تحتوي كتلة الوجه الأمامي على {{frontCount}} صفحة وكتلة الوجه الخلفي على {{backCount}} صفحة.\nيعني ذلك عادةً أن صفحة مُسحت مرتين أو أنها مفقودة.\n\nستُضاف الصفحات غير المقترنة ({{extraCount}}) في النهاية.\n\nهل تريد المتابعة على أي حال؟",
+ "invalidGroupTitle": "حجم مجموعة غير صالح",
+ "invalidGroupMessage": "يرجى إدخال عدد صفحات صالح لكل مستند أصلي.",
+ "collatingLoader": "جارٍ ترتيب المسح على الوجهين...",
+ "successMessage": "تم إنشاء ملف PDF المرتَّب بنجاح.",
+ "successGroupedMessage": "اكتمل الترتيب. تم إنشاء {{fileCount}} ملف مجمّع.",
+ "processErrorMessage": "تعذّر ترتيب ملف PDF.",
+ "howItWorksStep1Title": "ارفع الملف الممسوح",
+ "howItWorksStep1": "أفلت ملف PDF واحدًا يحتوي على جميع الوجوه الأمامية يليها جميع الوجوه الخلفية.",
+ "howItWorksStep2Title": "حدّد نقطة التقسيم",
+ "howItWorksStep2": "حدّد أين تنتهي كتلة الوجوه الأمامية، ثم اختر ما إذا كانت كتلة الوجوه الخلفية بحاجة إلى عكس.",
+ "howItWorksStep3Title": "رتّب ونزّل",
+ "howItWorksStep3": "احصل على ملف PDF واحد بترتيب متعاقب، أو ملف ZIP يحتوي على ملف منفصل لكل مستند أصلي.",
+ "faqOddPagesQuestion": "ماذا لو كان عدد صفحات الملف الممسوح فرديًا؟",
+ "faqOddPagesAnswer": "يجب أن يكون عدد صفحات المسح على الوجهين زوجيًا. إذا لم يكن كذلك، فغالبًا مُسحت صفحة مرتين أو فُقدت صفحة. تنبّهك الأداة قبل المعالجة، وتُضاف الصفحات غير المقترنة في النهاية حتى لا يضيع أي شيء.",
+ "faqBackOrderQuestion": "لماذا قد أُبقي كتلة الوجوه الخلفية كما هي؟",
+ "faqBackOrderAnswer": "يعتمد ذلك على طريقة إدخال الرزمة في التمريرة الثانية. قلب الرزمة بالكامل ينتج وجوهًا خلفية بترتيب معكوس، وهو الإعداد الافتراضي. أما إذا أعادها الماسح الضوئي بالترتيب نفسه للوجوه الأمامية، فاختر إبقاء الكتلة كما هي."
+ },
"addAttachments": {
"name": "إضافة مرفقات",
"subtitle": "تضمين ملف واحد أو أكثر في PDF الخاص بك."
diff --git a/public/locales/be/common.json b/public/locales/be/common.json
index 4df0b65a..693411f6 100644
--- a/public/locales/be/common.json
+++ b/public/locales/be/common.json
@@ -216,7 +216,7 @@
},
"analytics": {
"question": "Вы выкарыстоўваеце cookies або аналітыку, каб сачыць за мной?",
- "answer": "Мы дбаем пра вашу прыватнасць. BentoPDF не адсочвае персанальную інфармацыю. Мы выкарыстоўваем Simple Analytics, толькі каб бачыць ананімную статыстыку наведванняў. Гэта значыць, мы ведаем, колькі карыстальнікаў наведвае наш сайт, але ніколі не ведаем, хто вы. Simple Analytics цалкам адпавядае GDPR і шануе вашу прыватнасць."
+ "answer": "Не. BentoPDF вас ніяк не адсочвае. Мы не выкарыстоўваем файлы cookie, аналітыку ці скрыпты адсочвання — нават на працоўным сайце bentopdf.com. Мы ніколі не ведаем, хто вы і што вы робіце з інструментамі."
}
},
"testimonials": {
diff --git a/public/locales/be/tools.json b/public/locales/be/tools.json
index 8c9f4806..b5ad9ab0 100644
--- a/public/locales/be/tools.json
+++ b/public/locales/be/tools.json
@@ -55,6 +55,122 @@
"name": "Рэдактар PDF",
"subtitle": "Анатаваць, вылучыць, зацямніць, дадаць фігуры/відарысы, каментарыі, пошук і прагляд PDF."
},
+ "editPdfText": {
+ "name": "Рэдагаваць тэкст PDF",
+ "subtitle": "Націсніце на любы абзац і рэдагуйце яго на месцы з імгненнай перакампаноўкай тэксту, шрыфтамі і стылямі.",
+ "saved": "PDF паспяхова захаваны! ({{size}} КБ)",
+ "failedToLoad": "Не ўдалося загрузіць рэдактар тэксту PDF.",
+ "howToUpload": "Загрузіць PDF",
+ "howToClick": "Націсніце на абзац",
+ "howToRestyle": "Змяніце стыль",
+ "relatedPdfEditor": "Каментуйце і разметьце PDF",
+ "relatedSignPdf": "Намалюйце, увядзіце або загрузіце подпіс",
+ "relatedWatermark": "Нанясіце тэкст або выявы",
+ "relatedStamps": "Размяшчайце штампы на старонках",
+ "relatedCompress": "Паменшыце памер файла",
+ "relatedOrganizeName": "Упарадкаваць PDF",
+ "relatedOrganize": "Мяняйце парадак і кіруйце старонкамі",
+ "open": "Адкрыць",
+ "export": "Экспарт",
+ "filterAll": "Усе",
+ "filterText": "Тэкст",
+ "filterImages": "Выявы",
+ "filterShapes": "Фігуры",
+ "addText": "Дадаць тэкст",
+ "langAuto": "Аўта",
+ "change": "Змяніць",
+ "ignore": "Прапусціць",
+ "next": "Далей",
+ "replace": "Замяніць",
+ "replaceAll": "Замяніць усё",
+ "emptyState": "Адкрыйце PDF, каб рэдагаваць тэкст, выявы і фігуры.",
+ "choosePdf": "Выберыце PDF…",
+ "font": "Шрыфт",
+ "systemFonts": "Сістэмныя шрыфты",
+ "color": "Колер",
+ "alignment": "Выраўноўванне",
+ "style": "Стыль",
+ "lists": "Спісы",
+ "outline": "Абвядзенне",
+ "spacing": "Інтэрвал",
+ "direction": "Напрамак",
+ "dirAuto": "Аўта",
+ "pathStroke": "Абвядзенне контуру",
+ "tipBackToUpload": "Назад да загрузкі",
+ "tipOpenAnotherPdf": "Адкрыць іншы PDF",
+ "tipExportS": "Экспарт (⌘S)",
+ "tipUndoZ": "Адмяніць (⌘Z)",
+ "tipRedoZ": "Паўтарыць (⇧⌘Z)",
+ "tipFileDetails": "Звесткі пра файл",
+ "tipEditExistingContent": "Рэдагаваць наяўнае змесціва",
+ "tipWhatTheEditToolTouchesNarrowTheScopeOnBus": "На што дзейнічае інструмент рэдагавання — звузьце вобласць на насычаных старонках",
+ "tipClickThePageToAddATextBox": "Націсніце на старонку, каб дадаць тэкставае поле",
+ "tipSpellCheckLanguage": "Мова праверкі правапісу",
+ "tipFindAndReplace": "Знайсці і замяніць",
+ "tipAddAnImage": "Дадаць выяву",
+ "tipCopyThisPageSTextInReadingOrder": "Скапіяваць тэкст старонкі ў парадку чытання",
+ "tipCheckTheSpellingOfThisPage": "Праверыць правапіс на гэтай старонцы",
+ "tipShowOrHideTheFormatPanel": "Паказаць або схаваць панэль фарматавання",
+ "phReplaceWith": "Замяніць на",
+ "tipAddToYourDictionary": "Дадаць у слоўнік",
+ "tipClose": "Закрыць",
+ "phFind": "Знайсці",
+ "tipPreviousMatch": "Папярэдні супадзенне",
+ "tipNextMatch": "Наступнае супадзенне",
+ "tipRotateLeft90": "Павярнуць на 90° улева",
+ "tipRotateRight90": "Павярнуць на 90° управа",
+ "tipFlipHorizontal": "Адлюстраваць гарызантальна",
+ "tipFlipVertical": "Адлюстраваць вертыкальна",
+ "tipReplaceThisImageKeepingItsPlacement": "Замяніць выяву, захаваўшы размяшчэнне",
+ "tipAlternateTextForAccessibilityTaggedPdfs": "Альтэрнатыўны тэкст для даступнасці (тэгаваныя PDF)",
+ "tipBringToFront": "На пярэдні план",
+ "tipSendToBack": "На задні план",
+ "tipDuplicate": "Дубляваць",
+ "tipDelete": "Выдаліць",
+ "tipAlignLeftEdges": "Выраўнаваць па левых краях",
+ "tipAlignHorizontalCenters": "Цэнтраваць гарызантальна",
+ "tipAlignRightEdges": "Выраўнаваць па правых краях",
+ "tipAlignTops": "Выраўнаваць па верхніх краях",
+ "tipAlignVerticalCenters": "Цэнтраваць вертыкальна",
+ "tipAlignBottoms": "Выраўнаваць па ніжніх краях",
+ "tipDistributeHorizontally": "Размеркаваць гарызантальна",
+ "tipDistributeVertically": "Размеркаваць вертыкальна",
+ "tipFontSize": "Памер шрыфта",
+ "tipListFontsInstalledOnThisDeviceChromium": "Паказаць шрыфты, усталяваныя на гэтай прыладзе (Chromium)",
+ "tipCustomTextColor": "Уласны колер тэксту",
+ "tipAlignLeft": "Па левым краі",
+ "tipAlignCenter": "Па цэнтры",
+ "tipAlignRight": "Па правым краі",
+ "tipJustify": "Па шырыні",
+ "tipBold": "Тлусты",
+ "tipItalic": "Курсіў",
+ "tipUnderline": "Падкрэслены",
+ "tipStrikethrough": "Закрэслены",
+ "tipSuperscript": "Верхні індэкс",
+ "tipSubscript": "Ніжні індэкс",
+ "tipToggleBulletedItem": "Маркіраваны пункт",
+ "tipToggleNumberedItem": "Нумараваны пункт",
+ "tipNumberedListMarkerStyle": "Стыль маркера нумараваных спісаў",
+ "tipDecreaseListIndentTab": "Паменшыць водступ (⇧Tab)",
+ "tipIncreaseListIndentTab": "Павялічыць водступ (Tab)",
+ "tipTextOutlineColor": "Колер контуру тэксту",
+ "tipOutlineWidth0None": "Таўшчыня контуру (0 = няма)",
+ "tipFromContent": "Са змесціва",
+ "tipLeftToRight": "Злева направа",
+ "tipRightToLeft": "Справа налева",
+ "tipStrokeColor": "Колер абвядзення",
+ "tipStrokeWidth": "Таўшчыня абвядзення",
+ "tipZoomPresets": "Прадусталёўкі маштабу",
+ "tipZoomOut": "Паменшыць",
+ "tipZoomIn": "Павялічыць",
+ "tipFitPage": "Па памеры старонкі",
+ "tipPreviousPage": "Папярэдняя старонка",
+ "tipTypeAPageNumberAndPressEnter": "Увядзіце нумар старонкі і націсніце Enter",
+ "tipNextPage": "Наступная старонка",
+ "tipDocumentProperties": "Уласцівасці дакумента",
+ "tipHideBar": "Схаваць панэль",
+ "tipShowBar": "Паказаць панэль"
+ },
"jpgToPdf": {
"name": "JPG у PDF",
"subtitle": "Стварыць PDF з відарысаў JPG, JPEG і JPEG2000 (JP2/JPX)."
@@ -514,6 +630,51 @@
"retainPageLabels": "Захоўваць арыгінальныя пазнакі старонак з кожнага файла",
"retainPageLabelsHint": "Выключана (па змаўчанні): аб’яднаны PDF выкарыстоўвае натуральную нумарацыю 1–N. Уключана: арыгінальныя пазнакі старонак кожнага файла захоўваюцца (могуць узнікнуць паўторы)."
},
+ "duplexCollate": {
+ "name": "Сартаванне двухбаковага сканавання",
+ "subtitle": "Упарадкоўвае PDF, адсканаваны двума блокамі (спачатку ўсе пярэднія бакі, потым усе адваротныя), у правільны парадак пярэдні/адваротны.",
+ "totalPagesLabel": "Усяго старонак:",
+ "splitAfterPageLabel": "Падзяліць пасля старонкі",
+ "splitAfterPagePlaceholder": "напр. 50",
+ "backBlockOrderLabel": "Парадак блока адваротных бакоў",
+ "backBlockReverseOption": "Перавярнуць блок адваротных бакоў (звычайна пры перавернутым стосе)",
+ "backBlockKeepOption": "Пакінуць блок адваротных бакоў без змен",
+ "autoSplitButton": "Вызначыць падзел аўтаматычна (палова)",
+ "exportGroupedLabel": "Экспартаваць файлы групамі паводле даўжыні зыходнага дакумента",
+ "pagesPerDocumentLabel": "Старонак у зыходным дакуменце",
+ "groupedHelp": "Калі ўключана, вынікам будзе ZIP-архіў з асобным PDF для кожнага згрупаванага дакумента.",
+ "collateButton": "Сабраць PDF",
+ "invalidFileTitle": "Няправільны файл",
+ "invalidFileMessage": "Выберыце файл PDF.",
+ "loadingPdf": "Загрузка PDF...",
+ "loadErrorMessage": "Не ўдалося загрузіць файл PDF.",
+ "fileMeta": "{{size}} - {{count}} стар.",
+ "oddPageWarning": "Няцотная колькасць старонак ({{count}}). Двухбаковае сканаванне павінна мець цотную колькасць старонак; магчыма, старонкі не хапае або ёсць лішняя.",
+ "previewSummary": "Блок пярэдніх бакоў: {{frontCount}} стар., блок адваротных: {{backCount}} стар. Вынік: {{outputCount}} стар. у парадку пярэдні/адваротны.",
+ "previewMismatchWarning": "Даўжыня блокаў пярэдніх і адваротных бакоў адрозніваецца ({{frontCount}} пярэдніх супраць {{backCount}} адваротных). Лішнія старонкі ({{extraCount}}) з даўжэйшага блока будуць дададзены без пары ў канец.",
+ "missingFileTitle": "Файл не выбраны",
+ "missingFileMessage": "Перад апрацоўкай загрузіце PDF прынамсі з 2 старонкамі.",
+ "invalidSplitTitle": "Няправільны пункт падзелу",
+ "invalidSplitMessage": "Пункт падзелу павінен быць паміж старонкай 1 і старонкай {{maxSplit}}.",
+ "unevenConfirmTitle": "Няроўныя блокі",
+ "unevenConfirmMessage": "У блоку пярэдніх бакоў {{frontCount}} стар., у блоку адваротных — {{backCount}} стар.\nЗвычайна гэта азначае, што старонка адсканавана двойчы або яе не хапае.\n\nСтаронкі без пары ({{extraCount}}) будуць дададзены ў канец.\n\nУсё роўна працягнуць?",
+ "invalidGroupTitle": "Няправільны памер групы",
+ "invalidGroupMessage": "Увядзіце правільную колькасць старонак у зыходным дакуменце.",
+ "collatingLoader": "Збор двухбаковага сканавання...",
+ "successMessage": "Сабраны PDF паспяхова створаны.",
+ "successGroupedMessage": "Збор завершаны. Створана файлаў: {{fileCount}}.",
+ "processErrorMessage": "Не ўдалося сабраць PDF.",
+ "howItWorksStep1Title": "Загрузіце скан",
+ "howItWorksStep1": "Перацягніце адзін PDF, у якім спачатку ідуць усе пярэднія бакі, а потым усе адваротныя.",
+ "howItWorksStep2Title": "Задайце пункт падзелу",
+ "howItWorksStep2": "Пазначце, дзе заканчваецца блок пярэдніх бакоў, і выберыце, ці трэба перавярнуць блок адваротных.",
+ "howItWorksStep3Title": "Сабярыце і спампуйце",
+ "howItWorksStep3": "Вы атрымаеце адзін PDF з чаргаваннем старонак або ZIP-архіў з асобным файлам для кожнага зыходнага дакумента.",
+ "faqOddPagesQuestion": "Што рабіць, калі ў скане няцотная колькасць старонак?",
+ "faqOddPagesAnswer": "Двухбаковае сканаванне павінна мець цотную колькасць старонак. Калі гэта не так, старонка, хутчэй за ўсё, адсканавана двойчы або прапушчана. Інструмент папярэдзіць вас перад апрацоўкай, а старонкі без пары будуць дададзены ў канец, каб нічога не згубілася.",
+ "faqBackOrderQuestion": "Навошта пакідаць блок адваротных бакоў без змен?",
+ "faqBackOrderAnswer": "Гэта залежыць ад таго, як стос падавалі на другім праходзе. Калі перавярнуць увесь стос, адваротныя бакі ідуць у адваротным парадку — гэта варыянт па змаўчанні. Калі сканер выдаў іх у тым самым парадку, што і пярэднія бакі, выберыце пакінуць блок без змен."
+ },
"addAttachments": {
"name": "Дадаць далучэнні",
"subtitle": "Убудаваць адзін або некалькі файлаў у PDF."
diff --git a/public/locales/da/common.json b/public/locales/da/common.json
index fd9cf467..0a87b113 100644
--- a/public/locales/da/common.json
+++ b/public/locales/da/common.json
@@ -216,7 +216,7 @@
},
"analytics": {
"question": "Bruger I cookies eller analyseværktøjer?",
- "answer": "Vi bruger kun Simple Analytics til anonyme besøgsdata. Ingen personlige oplysninger indsamles."
+ "answer": "Nej. BentoPDF sporer dig slet ikke. Vi bruger hverken cookies, analyseværktøjer eller sporingsscripts – heller ikke på den live hjemmeside bentopdf.com. Vi ved aldrig, hvem du er, eller hvad du bruger værktøjerne til."
}
},
"testimonials": {
diff --git a/public/locales/da/tools.json b/public/locales/da/tools.json
index fb9cf225..a505aedc 100644
--- a/public/locales/da/tools.json
+++ b/public/locales/da/tools.json
@@ -55,6 +55,122 @@
"name": "PDF-editor",
"subtitle": "Annotér, fremhæv, redigér, kommentér, tilføj former/billeder, søg og vis PDF’er."
},
+ "editPdfText": {
+ "name": "Redigér PDF-tekst",
+ "subtitle": "Klik på et hvilket som helst afsnit, og redigér det direkte med live-ombrydning, skrifttyper og formatering.",
+ "saved": "PDF blev gemt! ({{size}} KB)",
+ "failedToLoad": "PDF-teksteditoren kunne ikke indlæses.",
+ "howToUpload": "Upload PDF",
+ "howToClick": "Klik på et afsnit",
+ "howToRestyle": "Giv alt ny stil",
+ "relatedPdfEditor": "Kommentér og markér PDF-filer",
+ "relatedSignPdf": "Tegn, skriv eller upload en underskrift",
+ "relatedWatermark": "Stempl tekst eller billeder",
+ "relatedStamps": "Placer stempler på sider",
+ "relatedCompress": "Reducer filstørrelsen",
+ "relatedOrganizeName": "Organiser PDF",
+ "relatedOrganize": "Omarranger og administrer sider",
+ "open": "Åbn",
+ "export": "Eksportér",
+ "filterAll": "Alle",
+ "filterText": "Tekst",
+ "filterImages": "Billeder",
+ "filterShapes": "Former",
+ "addText": "Tilføj tekst",
+ "langAuto": "Automatisk",
+ "change": "Skift",
+ "ignore": "Ignorér",
+ "next": "Næste",
+ "replace": "Erstat",
+ "replaceAll": "Erstat alle",
+ "emptyState": "Åbn en PDF for at redigere dens tekst, billeder og former.",
+ "choosePdf": "Vælg en PDF…",
+ "font": "Skrifttype",
+ "systemFonts": "Systemskrifttyper",
+ "color": "Farve",
+ "alignment": "Justering",
+ "style": "Stil",
+ "lists": "Lister",
+ "outline": "Kontur",
+ "spacing": "Afstand",
+ "direction": "Retning",
+ "dirAuto": "Automatisk",
+ "pathStroke": "Stikontur",
+ "tipBackToUpload": "Tilbage til upload",
+ "tipOpenAnotherPdf": "Åbn en anden PDF",
+ "tipExportS": "Eksportér (⌘S)",
+ "tipUndoZ": "Fortryd (⌘Z)",
+ "tipRedoZ": "Gentag (⇧⌘Z)",
+ "tipFileDetails": "Filoplysninger",
+ "tipEditExistingContent": "Rediger eksisterende indhold",
+ "tipWhatTheEditToolTouchesNarrowTheScopeOnBus": "Hvad Rediger-værktøjet berører — begræns omfanget på travle sider",
+ "tipClickThePageToAddATextBox": "Klik på siden for at tilføje et tekstfelt",
+ "tipSpellCheckLanguage": "Sprog for stavekontrol",
+ "tipFindAndReplace": "Søg og erstat",
+ "tipAddAnImage": "Tilføj et billede",
+ "tipCopyThisPageSTextInReadingOrder": "Kopiér sidens tekst i læserækkefølge",
+ "tipCheckTheSpellingOfThisPage": "Kontrollér stavningen på denne side",
+ "tipShowOrHideTheFormatPanel": "Vis eller skjul formatpanelet",
+ "phReplaceWith": "Erstat med",
+ "tipAddToYourDictionary": "Føj til ordbogen",
+ "tipClose": "Luk",
+ "phFind": "Søg",
+ "tipPreviousMatch": "Forrige match",
+ "tipNextMatch": "Næste match",
+ "tipRotateLeft90": "Rotér 90° mod venstre",
+ "tipRotateRight90": "Rotér 90° mod højre",
+ "tipFlipHorizontal": "Vend vandret",
+ "tipFlipVertical": "Vend lodret",
+ "tipReplaceThisImageKeepingItsPlacement": "Erstat dette billede, og behold placeringen",
+ "tipAlternateTextForAccessibilityTaggedPdfs": "Alternativ tekst til tilgængelighed (taggede PDF-filer)",
+ "tipBringToFront": "Placer forrest",
+ "tipSendToBack": "Placer bagerst",
+ "tipDuplicate": "Dublér",
+ "tipDelete": "Slet",
+ "tipAlignLeftEdges": "Justér venstre kanter",
+ "tipAlignHorizontalCenters": "Centrér vandret",
+ "tipAlignRightEdges": "Justér højre kanter",
+ "tipAlignTops": "Justér øverste kanter",
+ "tipAlignVerticalCenters": "Centrér lodret",
+ "tipAlignBottoms": "Justér nederste kanter",
+ "tipDistributeHorizontally": "Fordel vandret",
+ "tipDistributeVertically": "Fordel lodret",
+ "tipFontSize": "Skriftstørrelse",
+ "tipListFontsInstalledOnThisDeviceChromium": "Vis skrifttyper installeret på denne enhed (Chromium)",
+ "tipCustomTextColor": "Tilpasset tekstfarve",
+ "tipAlignLeft": "Justér til venstre",
+ "tipAlignCenter": "Centrér",
+ "tipAlignRight": "Justér til højre",
+ "tipJustify": "Lige margener",
+ "tipBold": "Fed",
+ "tipItalic": "Kursiv",
+ "tipUnderline": "Understreget",
+ "tipStrikethrough": "Gennemstreget",
+ "tipSuperscript": "Hævet skrift",
+ "tipSubscript": "Sænket skrift",
+ "tipToggleBulletedItem": "Slå punktopstilling til/fra",
+ "tipToggleNumberedItem": "Slå nummereret punkt til/fra",
+ "tipNumberedListMarkerStyle": "Markeringstype for nummererede lister",
+ "tipDecreaseListIndentTab": "Formindsk indrykning (⇧Tab)",
+ "tipIncreaseListIndentTab": "Forøg indrykning (Tab)",
+ "tipTextOutlineColor": "Farve på tekstkontur",
+ "tipOutlineWidth0None": "Konturbredde (0 = ingen)",
+ "tipFromContent": "Fra indhold",
+ "tipLeftToRight": "Venstre mod højre",
+ "tipRightToLeft": "Højre mod venstre",
+ "tipStrokeColor": "Stregfarve",
+ "tipStrokeWidth": "Stregbredde",
+ "tipZoomPresets": "Zoomforudindstillinger",
+ "tipZoomOut": "Zoom ud",
+ "tipZoomIn": "Zoom ind",
+ "tipFitPage": "Tilpas side",
+ "tipPreviousPage": "Forrige side",
+ "tipTypeAPageNumberAndPressEnter": "Skriv et sidetal, og tryk på Enter",
+ "tipNextPage": "Næste side",
+ "tipDocumentProperties": "Dokumentegenskaber",
+ "tipHideBar": "Skjul bjælke",
+ "tipShowBar": "Vis bjælke"
+ },
"jpgToPdf": {
"name": "JPG til PDF",
"subtitle": "Opret en PDF fra JPG, JPEG og JPEG2000 (JP2/JPX) billeder."
@@ -514,6 +630,51 @@
"retainPageLabels": "Behold de oprindelige sideetiketter fra hver fil",
"retainPageLabelsHint": "Fra (standard): den sammenflettede PDF bruger naturlig sidenummerering 1–N. Til: hver fils oprindelige sideetiketter bevares (kan give dublerede etiketter)."
},
+ "duplexCollate": {
+ "name": "Sortér dupleks-scanning",
+ "subtitle": "Omarrangér en PDF, der er scannet i to blokke (først alle forsider, derefter alle bagsider), til den rigtige for-/bagsiderækkefølge.",
+ "totalPagesLabel": "Sider i alt:",
+ "splitAfterPageLabel": "Opdel efter side",
+ "splitAfterPagePlaceholder": "f.eks. 50",
+ "backBlockOrderLabel": "Rækkefølge for bagsideblok",
+ "backBlockReverseOption": "Vend bagsideblokken om (almindeligt ved vendt stak)",
+ "backBlockKeepOption": "Behold bagsideblokken, som den er",
+ "autoSplitButton": "Find opdeling automatisk (halvdelen)",
+ "exportGroupedLabel": "Eksportér grupperede filer efter det oprindelige dokuments længde",
+ "pagesPerDocumentLabel": "Sider pr. oprindeligt dokument",
+ "groupedHelp": "Når det er slået til, er resultatet en ZIP-fil med én PDF pr. grupperet dokument.",
+ "collateButton": "Sortér PDF",
+ "invalidFileTitle": "Ugyldig fil",
+ "invalidFileMessage": "Vælg en PDF-fil.",
+ "loadingPdf": "Indlæser PDF...",
+ "loadErrorMessage": "PDF-filen kunne ikke indlæses.",
+ "fileMeta": "{{size}} - {{count}} sider",
+ "oddPageWarning": "Ulige sidetal ({{count}}). En dupleks-scanning bør have et lige antal sider; der mangler måske en side, eller der er en for meget.",
+ "previewSummary": "Forsideblok: {{frontCount}} side(r), bagsideblok: {{backCount}} side(r). Resultat: {{outputCount}} side(r) i for-/bagsiderækkefølge.",
+ "previewMismatchWarning": "For- og bagsideblokkene har forskellig længde ({{frontCount}} forsider mod {{backCount}} bagsider). De {{extraCount}} overskydende side(r) i den længste blok tilføjes uden makker til sidst.",
+ "missingFileTitle": "Manglende fil",
+ "missingFileMessage": "Upload en PDF med mindst 2 sider, før du behandler den.",
+ "invalidSplitTitle": "Ugyldigt opdelingspunkt",
+ "invalidSplitMessage": "Opdelingspunktet skal ligge mellem side 1 og side {{maxSplit}}.",
+ "unevenConfirmTitle": "Ulige blokke",
+ "unevenConfirmMessage": "Forsideblokken har {{frontCount}} side(r), og bagsideblokken har {{backCount}} side(r).\nDet betyder som regel, at en side er scannet to gange eller mangler.\n\nDe {{extraCount}} side(r) uden makker tilføjes til sidst.\n\nVil du fortsætte alligevel?",
+ "invalidGroupTitle": "Ugyldig gruppestørrelse",
+ "invalidGroupMessage": "Angiv et gyldigt antal sider pr. oprindeligt dokument.",
+ "collatingLoader": "Sorterer dupleks-scanning...",
+ "successMessage": "Den sorterede PDF blev oprettet.",
+ "successGroupedMessage": "Sortering fuldført. Der blev oprettet {{fileCount}} grupperede filer.",
+ "processErrorMessage": "PDF'en kunne ikke sorteres.",
+ "howItWorksStep1Title": "Upload scanningen",
+ "howItWorksStep1": "Slip den ene PDF, der indeholder alle forsider efterfulgt af alle bagsider.",
+ "howItWorksStep2Title": "Angiv opdelingspunktet",
+ "howItWorksStep2": "Markér, hvor forsideblokken slutter, og vælg, om bagsideblokken skal vendes om.",
+ "howItWorksStep3Title": "Sortér og download",
+ "howItWorksStep3": "Du får én PDF med sammenflettede sider eller en ZIP-fil med en separat fil pr. oprindeligt dokument.",
+ "faqOddPagesQuestion": "Hvad hvis min scanning har et ulige antal sider?",
+ "faqOddPagesAnswer": "En dupleks-scanning bør have et lige antal sider. Hvis den ikke har det, er en side sandsynligvis scannet to gange eller sprunget over. Værktøjet advarer dig før behandlingen, og sider uden makker tilføjes til sidst, så intet går tabt.",
+ "faqBackOrderQuestion": "Hvorfor skulle jeg beholde bagsideblokken, som den er?",
+ "faqBackOrderAnswer": "Det afhænger af, hvordan stakken blev lagt i ved anden kørsel. Hvis hele stakken vendes, kommer bagsiderne i omvendt rækkefølge, hvilket er standardindstillingen. Hvis din scanner leverede dem i samme rækkefølge som forsiderne, så vælg at beholde blokken, som den er."
+ },
"addAttachments": {
"name": "Tilføj vedhæftninger",
"subtitle": "Indlejr en eller flere filer i din PDF."
diff --git a/public/locales/de/common.json b/public/locales/de/common.json
index 446893ae..f5b13cbe 100644
--- a/public/locales/de/common.json
+++ b/public/locales/de/common.json
@@ -216,7 +216,7 @@
},
"analytics": {
"question": "Verwendet ihr Cookies oder Analysen, um mich zu verfolgen?",
- "answer": "Uns liegt deine Privatsphäre am Herzen. BentoPDF verfolgt keine persönlichen Informationen. Wir verwenden Simple Analytics ausschließlich, um anonyme Besucherzahlen zu sehen. Das bedeutet, wir können wissen, wie viele Benutzer unsere Seite besuchen, aber wir wissen nie, wer sie sind. Simple Analytics ist vollständig DSGVO-konform und respektiert deine Privatsphäre."
+ "answer": "Nein. BentoPDF verfolgt Sie in keiner Weise. Wir verwenden keine Cookies, keine Analysetools und keine Tracking-Skripte – auch nicht auf der Live-Website bentopdf.com. Wir wissen nie, wer Sie sind oder was Sie mit den Tools tun."
}
},
"testimonials": {
diff --git a/public/locales/de/tools.json b/public/locales/de/tools.json
index 4c720fe1..f6c8df25 100644
--- a/public/locales/de/tools.json
+++ b/public/locales/de/tools.json
@@ -55,6 +55,122 @@
"name": "PDF-Editor",
"subtitle": "Annotieren, hervorheben, schwärzen, kommentieren, Formen/Bilder hinzufügen, suchen und PDFs anzeigen."
},
+ "editPdfText": {
+ "name": "PDF-Text bearbeiten",
+ "subtitle": "Auf einen beliebigen Absatz klicken und ihn direkt bearbeiten – mit Live-Umbruch, Schriftarten und Formatierung.",
+ "saved": "PDF erfolgreich gespeichert! ({{size}} KB)",
+ "failedToLoad": "Der PDF-Texteditor konnte nicht geladen werden.",
+ "howToUpload": "PDF hochladen",
+ "howToClick": "Absatz anklicken",
+ "howToRestyle": "Alles umgestalten",
+ "relatedPdfEditor": "PDFs kommentieren und markieren",
+ "relatedSignPdf": "Unterschrift zeichnen, tippen oder hochladen",
+ "relatedWatermark": "Text oder Bilder aufbringen",
+ "relatedStamps": "Stempel auf Seiten platzieren",
+ "relatedCompress": "Dateigröße verringern",
+ "relatedOrganizeName": "PDF organisieren",
+ "relatedOrganize": "Seiten neu anordnen und verwalten",
+ "open": "Öffnen",
+ "export": "Exportieren",
+ "filterAll": "Alle",
+ "filterText": "Text",
+ "filterImages": "Bilder",
+ "filterShapes": "Formen",
+ "addText": "Text hinzufügen",
+ "langAuto": "Automatisch",
+ "change": "Ändern",
+ "ignore": "Ignorieren",
+ "next": "Weiter",
+ "replace": "Ersetzen",
+ "replaceAll": "Alle ersetzen",
+ "emptyState": "Öffnen Sie eine PDF, um Text, Bilder und Formen zu bearbeiten.",
+ "choosePdf": "PDF auswählen …",
+ "font": "Schriftart",
+ "systemFonts": "Systemschriften",
+ "color": "Farbe",
+ "alignment": "Ausrichtung",
+ "style": "Stil",
+ "lists": "Listen",
+ "outline": "Kontur",
+ "spacing": "Abstand",
+ "direction": "Richtung",
+ "dirAuto": "Automatisch",
+ "pathStroke": "Pfadkontur",
+ "tipBackToUpload": "Zurück zum Upload",
+ "tipOpenAnotherPdf": "Weitere PDF öffnen",
+ "tipExportS": "Exportieren (⌘S)",
+ "tipUndoZ": "Rückgängig (⌘Z)",
+ "tipRedoZ": "Wiederherstellen (⇧⌘Z)",
+ "tipFileDetails": "Dateidetails",
+ "tipEditExistingContent": "Vorhandene Inhalte bearbeiten",
+ "tipWhatTheEditToolTouchesNarrowTheScopeOnBus": "Was das Bearbeiten-Werkzeug erfasst – Umfang auf vollen Seiten eingrenzen",
+ "tipClickThePageToAddATextBox": "Klicken Sie auf die Seite, um ein Textfeld hinzuzufügen",
+ "tipSpellCheckLanguage": "Sprache der Rechtschreibprüfung",
+ "tipFindAndReplace": "Suchen & ersetzen",
+ "tipAddAnImage": "Bild hinzufügen",
+ "tipCopyThisPageSTextInReadingOrder": "Text dieser Seite in Lesereihenfolge kopieren",
+ "tipCheckTheSpellingOfThisPage": "Rechtschreibung dieser Seite prüfen",
+ "tipShowOrHideTheFormatPanel": "Formatbereich ein- oder ausblenden",
+ "phReplaceWith": "Ersetzen durch",
+ "tipAddToYourDictionary": "Zum Wörterbuch hinzufügen",
+ "tipClose": "Schließen",
+ "phFind": "Suchen",
+ "tipPreviousMatch": "Vorheriger Treffer",
+ "tipNextMatch": "Nächster Treffer",
+ "tipRotateLeft90": "90° nach links drehen",
+ "tipRotateRight90": "90° nach rechts drehen",
+ "tipFlipHorizontal": "Horizontal spiegeln",
+ "tipFlipVertical": "Vertikal spiegeln",
+ "tipReplaceThisImageKeepingItsPlacement": "Dieses Bild ersetzen, Position beibehalten",
+ "tipAlternateTextForAccessibilityTaggedPdfs": "Alternativtext für Barrierefreiheit (getaggte PDFs)",
+ "tipBringToFront": "In den Vordergrund",
+ "tipSendToBack": "In den Hintergrund",
+ "tipDuplicate": "Duplizieren",
+ "tipDelete": "Löschen",
+ "tipAlignLeftEdges": "Linke Kanten ausrichten",
+ "tipAlignHorizontalCenters": "Horizontal zentrieren",
+ "tipAlignRightEdges": "Rechte Kanten ausrichten",
+ "tipAlignTops": "Oberkanten ausrichten",
+ "tipAlignVerticalCenters": "Vertikal zentrieren",
+ "tipAlignBottoms": "Unterkanten ausrichten",
+ "tipDistributeHorizontally": "Horizontal verteilen",
+ "tipDistributeVertically": "Vertikal verteilen",
+ "tipFontSize": "Schriftgröße",
+ "tipListFontsInstalledOnThisDeviceChromium": "Auf diesem Gerät installierte Schriften auflisten (Chromium)",
+ "tipCustomTextColor": "Benutzerdefinierte Textfarbe",
+ "tipAlignLeft": "Linksbündig",
+ "tipAlignCenter": "Zentriert",
+ "tipAlignRight": "Rechtsbündig",
+ "tipJustify": "Blocksatz",
+ "tipBold": "Fett",
+ "tipItalic": "Kursiv",
+ "tipUnderline": "Unterstrichen",
+ "tipStrikethrough": "Durchgestrichen",
+ "tipSuperscript": "Hochgestellt",
+ "tipSubscript": "Tiefgestellt",
+ "tipToggleBulletedItem": "Aufzählungspunkt umschalten",
+ "tipToggleNumberedItem": "Nummerierten Punkt umschalten",
+ "tipNumberedListMarkerStyle": "Markierungsstil für nummerierte Listen",
+ "tipDecreaseListIndentTab": "Listeneinzug verringern (⇧Tab)",
+ "tipIncreaseListIndentTab": "Listeneinzug vergrößern (Tab)",
+ "tipTextOutlineColor": "Textkonturfarbe",
+ "tipOutlineWidth0None": "Konturbreite (0 = keine)",
+ "tipFromContent": "Aus Inhalt",
+ "tipLeftToRight": "Von links nach rechts",
+ "tipRightToLeft": "Von rechts nach links",
+ "tipStrokeColor": "Linienfarbe",
+ "tipStrokeWidth": "Linienbreite",
+ "tipZoomPresets": "Zoom-Voreinstellungen",
+ "tipZoomOut": "Verkleinern",
+ "tipZoomIn": "Vergrößern",
+ "tipFitPage": "Seite einpassen",
+ "tipPreviousPage": "Vorherige Seite",
+ "tipTypeAPageNumberAndPressEnter": "Seitenzahl eingeben und Enter drücken",
+ "tipNextPage": "Nächste Seite",
+ "tipDocumentProperties": "Dokumenteigenschaften",
+ "tipHideBar": "Leiste ausblenden",
+ "tipShowBar": "Leiste einblenden"
+ },
"jpgToPdf": {
"name": "JPG zu PDF",
"subtitle": "Eine PDF aus JPG, JPEG und JPEG2000 (JP2/JPX) Bildern erstellen."
@@ -514,6 +630,51 @@
"retainPageLabels": "Originale Seitenbeschriftungen jeder Datei beibehalten",
"retainPageLabelsHint": "Aus (Standard): Das zusammengeführte PDF verwendet die natürliche Seitennummerierung 1–N. An: Die ursprünglichen Seitenbeschriftungen jeder Datei bleiben erhalten (kann zu doppelten Beschriftungen führen)."
},
+ "duplexCollate": {
+ "name": "Seitenfolge für Duplex-Scans neu anordnen",
+ "subtitle": "Ein PDF, das in zwei Blöcken gescannt wurde (erst alle Vorderseiten, dann alle Rückseiten), in die richtige Vorder-/Rückseiten-Reihenfolge bringen.",
+ "totalPagesLabel": "Seiten gesamt:",
+ "splitAfterPageLabel": "Teilen nach Seite",
+ "splitAfterPagePlaceholder": "z. B. 50",
+ "backBlockOrderLabel": "Reihenfolge des Rückseiten-Blocks",
+ "backBlockReverseOption": "Rückseiten-Block umkehren (üblich bei umgedrehtem Stapel)",
+ "backBlockKeepOption": "Rückseiten-Block unverändert lassen",
+ "autoSplitButton": "Teilung automatisch erkennen (Hälfte)",
+ "exportGroupedLabel": "Gruppierte Dateien nach ursprünglicher Dokumentlänge exportieren",
+ "pagesPerDocumentLabel": "Seiten pro Originaldokument",
+ "groupedHelp": "Wenn aktiviert, ist die Ausgabe eine ZIP-Datei mit einer PDF-Datei pro gruppiertem Dokument.",
+ "collateButton": "PDF zusammenführen",
+ "invalidFileTitle": "Ungültige Datei",
+ "invalidFileMessage": "Bitte wählen Sie eine PDF-Datei aus.",
+ "loadingPdf": "PDF wird geladen...",
+ "loadErrorMessage": "PDF-Datei konnte nicht geladen werden.",
+ "fileMeta": "{{size}} - {{count}} Seiten",
+ "oddPageWarning": "Ungerade Seitenzahl ({{count}}). Ein Duplex-Scan sollte eine gerade Seitenzahl haben; möglicherweise fehlt eine Seite oder es ist eine zu viel.",
+ "previewSummary": "Vorderseiten-Block: {{frontCount}} Seite(n), Rückseiten-Block: {{backCount}} Seite(n). Ausgabe: {{outputCount}} Seite(n) in Vorder-/Rückseiten-Reihenfolge.",
+ "previewMismatchWarning": "Die Blocklängen von Vorder- und Rückseiten unterscheiden sich ({{frontCount}} Vorderseiten gegenüber {{backCount}} Rückseiten). Die {{extraCount}} überzähligen Seite(n) der längeren Seite werden ohne Gegenstück am Ende angehängt.",
+ "missingFileTitle": "Fehlende Datei",
+ "missingFileMessage": "Bitte laden Sie vor der Verarbeitung ein PDF mit mindestens 2 Seiten hoch.",
+ "invalidSplitTitle": "Ungültiger Teilungspunkt",
+ "invalidSplitMessage": "Der Teilungspunkt muss zwischen Seite 1 und Seite {{maxSplit}} liegen.",
+ "unevenConfirmTitle": "Ungleiche Blöcke",
+ "unevenConfirmMessage": "Der Vorderseiten-Block hat {{frontCount}} Seite(n) und der Rückseiten-Block {{backCount}} Seite(n).\nMeist bedeutet das, dass eine Seite doppelt gescannt wurde oder fehlt.\n\nDie {{extraCount}} Seite(n) ohne Gegenstück werden am Ende angehängt.\n\nTrotzdem fortfahren?",
+ "invalidGroupTitle": "Ungültige Gruppengröße",
+ "invalidGroupMessage": "Bitte geben Sie eine gültige Seitenzahl pro Originaldokument ein.",
+ "collatingLoader": "Duplex-Scan wird zusammengeführt...",
+ "successMessage": "Zusammengeführtes PDF wurde erfolgreich erstellt.",
+ "successGroupedMessage": "Zusammenführung abgeschlossen. {{fileCount}} gruppierte Datei(en) erstellt.",
+ "processErrorMessage": "PDF konnte nicht zusammengeführt werden.",
+ "howItWorksStep1Title": "Scan hochladen",
+ "howItWorksStep1": "Laden Sie das einzelne PDF hoch, das erst alle Vorderseiten und danach alle Rückseiten enthält.",
+ "howItWorksStep2Title": "Teilungspunkt festlegen",
+ "howItWorksStep2": "Geben Sie an, wo der Vorderseiten-Block endet, und wählen Sie, ob der Rückseiten-Block umgekehrt werden muss.",
+ "howItWorksStep3Title": "Zusammenführen und herunterladen",
+ "howItWorksStep3": "Sie erhalten ein ineinander sortiertes PDF oder eine ZIP-Datei mit einer eigenen Datei pro Originaldokument.",
+ "faqOddPagesQuestion": "Was ist, wenn mein Scan eine ungerade Seitenzahl hat?",
+ "faqOddPagesAnswer": "Ein Duplex-Scan sollte eine gerade Seitenzahl haben. Ist das nicht der Fall, wurde vermutlich eine Seite doppelt gescannt oder übersprungen. Das Werkzeug warnt Sie vor der Verarbeitung, und Seiten ohne Gegenstück werden am Ende angehängt, damit nichts verloren geht.",
+ "faqBackOrderQuestion": "Warum sollte ich den Rückseiten-Block unverändert lassen?",
+ "faqBackOrderAnswer": "Das hängt davon ab, wie der Stapel für den zweiten Durchgang eingelegt wurde. Wird der gesamte Stapel umgedreht, liegen die Rückseiten in umgekehrter Reihenfolge vor – das ist die Standardeinstellung. Hat Ihr Scanner sie in derselben Reihenfolge wie die Vorderseiten ausgegeben, lassen Sie den Block unverändert."
+ },
"addAttachments": {
"name": "Anhänge hinzufügen",
"subtitle": "Eine oder mehrere Dateien in Ihre PDF einbetten."
diff --git a/public/locales/en/common.json b/public/locales/en/common.json
index 17af04df..dde4e4fd 100644
--- a/public/locales/en/common.json
+++ b/public/locales/en/common.json
@@ -216,7 +216,7 @@
},
"analytics": {
"question": "Do you use cookies or analytics to track me?",
- "answer": "We care about your privacy. BentoPDF does not track personal information. We use Simple Analytics solely to see anonymous visit counts. This means we can know how many users visit our site, but we never know who you are. Simple Analytics is fully GDPR-compliant and respects your privacy."
+ "answer": "No. BentoPDF does not track you at all. We do not use cookies, analytics, or any tracking scripts, not even on the live website at bentopdf.com. We never know who you are or what you do with the tools."
}
},
"testimonials": {
diff --git a/public/locales/en/tools.json b/public/locales/en/tools.json
index ec970815..c553d5a4 100644
--- a/public/locales/en/tools.json
+++ b/public/locales/en/tools.json
@@ -13,13 +13,14 @@
"convertToPdf": "Convert to PDF",
"pdfMultiTool": {
"name": "PDF Multi Tool",
- "subtitle": "Merge, Split, Organize, Delete, Rotate, Add Blank Pages, Extract and Duplicate in an unified interface."
+ "subtitle": "Merge, Split, Organize, Delete, Rotate, Add Blank Pages, Extract and Duplicate in a unified interface."
},
"mergePdf": {
"name": "Merge PDF",
"subtitle": "Combine multiple PDFs into one file. Preserves Bookmarks.",
"retainPageLabels": "Keep original page labels from each file",
- "retainPageLabelsHint": "Off (default): the merged PDF uses natural page numbering 1–N. On: each file's original page labels are preserved (may produce duplicate labels)."
+ "retainPageLabelsHint": "Off (default): the merged PDF uses natural page numbering 1–N. On: each file's original page labels are preserved (may produce duplicate labels).",
+ "removeDuplicateFonts": "Remove duplicate fonts"
},
"splitPdf": {
"name": "Split PDF",
@@ -55,6 +56,122 @@
"name": "PDF Editor",
"subtitle": "Annotate, highlight, redact, comment, add shapes/images, search, and view PDFs."
},
+ "editPdfText": {
+ "name": "Edit PDF Text",
+ "subtitle": "Click any paragraph and edit it in place with live reflow, fonts, and styling.",
+ "saved": "PDF saved successfully! ({{size}} KB)",
+ "failedToLoad": "Failed to load the PDF Text Editor.",
+ "howToUpload": "Upload PDF",
+ "howToClick": "Click a Paragraph",
+ "howToRestyle": "Restyle Anything",
+ "relatedPdfEditor": "Annotate and mark up PDFs",
+ "relatedSignPdf": "Draw, type or upload a signature",
+ "relatedWatermark": "Stamp text or images",
+ "relatedStamps": "Place stamps on pages",
+ "relatedCompress": "Shrink the file size",
+ "relatedOrganizeName": "Organize PDF",
+ "relatedOrganize": "Reorder and manage pages",
+ "open": "Open",
+ "export": "Export",
+ "filterAll": "All",
+ "filterText": "Text",
+ "filterImages": "Images",
+ "filterShapes": "Shapes",
+ "addText": "Add Text",
+ "langAuto": "Auto",
+ "change": "Change",
+ "ignore": "Ignore",
+ "next": "Next",
+ "replace": "Replace",
+ "replaceAll": "Replace All",
+ "emptyState": "Open a PDF to edit its text, images and shapes.",
+ "choosePdf": "Choose a PDF…",
+ "font": "Font",
+ "systemFonts": "System fonts",
+ "color": "Color",
+ "alignment": "Alignment",
+ "style": "Style",
+ "lists": "Lists",
+ "outline": "Outline",
+ "spacing": "Spacing",
+ "direction": "Direction",
+ "dirAuto": "Auto",
+ "pathStroke": "Path stroke",
+ "tipBackToUpload": "Back to upload",
+ "tipOpenAnotherPdf": "Open another PDF",
+ "tipExportS": "Export (⌘S)",
+ "tipUndoZ": "Undo (⌘Z)",
+ "tipRedoZ": "Redo (⇧⌘Z)",
+ "tipFileDetails": "File details",
+ "tipEditExistingContent": "Edit existing content",
+ "tipWhatTheEditToolTouchesNarrowTheScopeOnBus": "What the Edit tool touches — narrow the scope on busy pages",
+ "tipClickThePageToAddATextBox": "Click the page to add a text box",
+ "tipSpellCheckLanguage": "Spell-check language",
+ "tipFindAndReplace": "Find & replace",
+ "tipAddAnImage": "Add an image",
+ "tipCopyThisPageSTextInReadingOrder": "Copy this page's text in reading order",
+ "tipCheckTheSpellingOfThisPage": "Check the spelling of this page",
+ "tipShowOrHideTheFormatPanel": "Show or hide the format panel",
+ "phReplaceWith": "Replace with",
+ "tipAddToYourDictionary": "Add to your dictionary",
+ "tipClose": "Close",
+ "phFind": "Find",
+ "tipPreviousMatch": "Previous match",
+ "tipNextMatch": "Next match",
+ "tipRotateLeft90": "Rotate left 90°",
+ "tipRotateRight90": "Rotate right 90°",
+ "tipFlipHorizontal": "Flip horizontal",
+ "tipFlipVertical": "Flip vertical",
+ "tipReplaceThisImageKeepingItsPlacement": "Replace this image, keeping its placement",
+ "tipAlternateTextForAccessibilityTaggedPdfs": "Alternate text for accessibility (tagged PDFs)",
+ "tipBringToFront": "Bring to front",
+ "tipSendToBack": "Send to back",
+ "tipDuplicate": "Duplicate",
+ "tipDelete": "Delete",
+ "tipAlignLeftEdges": "Align left edges",
+ "tipAlignHorizontalCenters": "Align horizontal centers",
+ "tipAlignRightEdges": "Align right edges",
+ "tipAlignTops": "Align tops",
+ "tipAlignVerticalCenters": "Align vertical centers",
+ "tipAlignBottoms": "Align bottoms",
+ "tipDistributeHorizontally": "Distribute horizontally",
+ "tipDistributeVertically": "Distribute vertically",
+ "tipFontSize": "Font size",
+ "tipListFontsInstalledOnThisDeviceChromium": "List fonts installed on this device (Chromium)",
+ "tipCustomTextColor": "Custom text color",
+ "tipAlignLeft": "Align left",
+ "tipAlignCenter": "Align center",
+ "tipAlignRight": "Align right",
+ "tipJustify": "Justify",
+ "tipBold": "Bold",
+ "tipItalic": "Italic",
+ "tipUnderline": "Underline",
+ "tipStrikethrough": "Strikethrough",
+ "tipSuperscript": "Superscript",
+ "tipSubscript": "Subscript",
+ "tipToggleBulletedItem": "Toggle bulleted item",
+ "tipToggleNumberedItem": "Toggle numbered item",
+ "tipNumberedListMarkerStyle": "Numbered-list marker style",
+ "tipDecreaseListIndentTab": "Decrease list indent (⇧Tab)",
+ "tipIncreaseListIndentTab": "Increase list indent (Tab)",
+ "tipTextOutlineColor": "Text outline color",
+ "tipOutlineWidth0None": "Outline width (0 = none)",
+ "tipFromContent": "From content",
+ "tipLeftToRight": "Left to right",
+ "tipRightToLeft": "Right to left",
+ "tipStrokeColor": "Stroke color",
+ "tipStrokeWidth": "Stroke width",
+ "tipZoomPresets": "Zoom presets",
+ "tipZoomOut": "Zoom out",
+ "tipZoomIn": "Zoom in",
+ "tipFitPage": "Fit page",
+ "tipPreviousPage": "Previous page",
+ "tipTypeAPageNumberAndPressEnter": "Type a page number and press Enter",
+ "tipNextPage": "Next page",
+ "tipDocumentProperties": "Document properties",
+ "tipHideBar": "Hide bar",
+ "tipShowBar": "Show bar"
+ },
"jpgToPdf": {
"name": "JPG to PDF",
"subtitle": "Create a PDF from JPG, JPEG, and JPEG2000 (JP2/JPX) images."
@@ -399,7 +516,8 @@
"noFile": "No File",
"noFileExplanation": "Please upload a PDF file first.",
"conversionSuccess": "PDF converted to TIFFs successfully!",
- "conversionError": "Failed to convert PDF to TIFF. The file might be corrupted."
+ "conversionError": "Failed to convert PDF to TIFF. The file might be corrupted.",
+ "conversionOutOfMemory": "The browser ran out of memory while converting. Try a lower DPI, or uncheck the multi-page TIFF option to convert pages one at a time."
}
},
"pdfToCbz": {
@@ -514,6 +632,51 @@
"retainPageLabels": "Keep original page labels from each file",
"retainPageLabelsHint": "Off (default): the merged PDF uses natural page numbering 1–N. On: each file's original page labels are preserved (may produce duplicate labels)."
},
+ "duplexCollate": {
+ "name": "Duplex Collate",
+ "subtitle": "Reorder a PDF scanned as two blocks (all fronts, then all backs) into proper front/back order.",
+ "totalPagesLabel": "Total pages:",
+ "splitAfterPageLabel": "Split after page",
+ "splitAfterPagePlaceholder": "e.g. 50",
+ "backBlockOrderLabel": "Back block order",
+ "backBlockReverseOption": "Reverse back block (common for flipped stacks)",
+ "backBlockKeepOption": "Keep back block as-is",
+ "autoSplitButton": "Auto-detect split (half)",
+ "exportGroupedLabel": "Export grouped files by original document length",
+ "pagesPerDocumentLabel": "Pages per original document",
+ "groupedHelp": "If enabled, output is a ZIP containing one PDF per grouped document.",
+ "collateButton": "Collate PDF",
+ "invalidFileTitle": "Invalid File",
+ "invalidFileMessage": "Please select a PDF file.",
+ "loadingPdf": "Loading PDF...",
+ "loadErrorMessage": "Failed to load PDF file.",
+ "fileMeta": "{{size}} - {{count}} pages",
+ "oddPageWarning": "Odd page count ({{count}}). A duplex scan should have an even number of pages; you may have a missing or extra page.",
+ "previewSummary": "Front block: {{frontCount}} page(s), back block: {{backCount}} page(s). Output: {{outputCount}} page(s), in front/back sequence.",
+ "previewMismatchWarning": "Front and back block lengths differ ({{frontCount}} front vs {{backCount}} back). The {{extraCount}} extra page(s) on the longer side will be appended unpaired at the end.",
+ "missingFileTitle": "Missing File",
+ "missingFileMessage": "Please upload a PDF with at least 2 pages before processing.",
+ "invalidSplitTitle": "Invalid Split Point",
+ "invalidSplitMessage": "Split point must be between page 1 and page {{maxSplit}}.",
+ "unevenConfirmTitle": "Uneven Blocks",
+ "unevenConfirmMessage": "The front block has {{frontCount}} page(s) and the back block has {{backCount}} page(s).\nThis usually means a page was scanned twice or is missing.\n\nThe {{extraCount}} unpaired page(s) will be appended at the end.\n\nContinue anyway?",
+ "invalidGroupTitle": "Invalid Group Size",
+ "invalidGroupMessage": "Please enter a valid number of pages per original document.",
+ "collatingLoader": "Collating duplex scan...",
+ "successMessage": "Collated PDF generated successfully.",
+ "successGroupedMessage": "Collation complete. Generated {{fileCount}} grouped file(s).",
+ "processErrorMessage": "Failed to collate PDF.",
+ "howItWorksStep1Title": "Upload the scan",
+ "howItWorksStep1": "Drop in the single PDF that holds all the front sides followed by all the back sides.",
+ "howItWorksStep2Title": "Set the split point",
+ "howItWorksStep2": "Mark where the front block ends, then choose whether the back block needs reversing.",
+ "howItWorksStep3Title": "Collate and download",
+ "howItWorksStep3": "Get one interleaved PDF, or a ZIP with a separate file per original document.",
+ "faqOddPagesQuestion": "What if my scan has an odd number of pages?",
+ "faqOddPagesAnswer": "A duplex scan should have an even page count. If it does not, a page was probably scanned twice or missed. The tool warns you before processing, and any unpaired pages are appended at the end so nothing is lost.",
+ "faqBackOrderQuestion": "Why would I keep the back block as-is?",
+ "faqBackOrderAnswer": "It depends on how the stack was fed for the second pass. Flipping the whole stack over produces back sides in reverse order, which is the default. If your scanner returned them in the same order as the fronts, choose to keep the block as-is."
+ },
"addAttachments": {
"name": "Add Attachments",
"subtitle": "Embed one or more files into your PDF."
diff --git a/public/locales/es/common.json b/public/locales/es/common.json
index 3f0a6a51..9549a991 100644
--- a/public/locales/es/common.json
+++ b/public/locales/es/common.json
@@ -216,7 +216,7 @@
},
"analytics": {
"question": "¿Usan cookies o análisis para rastrearme?",
- "answer": "Nos preocupamos por tu privacidad. BentoPDF no rastrea información personal. Usamos Simple Analytics únicamente para ver recuentos de visitas anónimas. Esto significa que podemos saber cuántos usuarios visitan nuestro sitio, pero nunca sabemos quién eres. Simple Analytics cumple completamente con GDPR y respeta tu privacidad."
+ "answer": "No. BentoPDF no te rastrea en absoluto. No usamos cookies, analíticas ni scripts de seguimiento, ni siquiera en el sitio web en vivo bentopdf.com. Nunca sabemos quién eres ni qué haces con las herramientas."
}
},
"testimonials": {
diff --git a/public/locales/es/tools.json b/public/locales/es/tools.json
index 0d0a7609..50cd32b0 100644
--- a/public/locales/es/tools.json
+++ b/public/locales/es/tools.json
@@ -55,6 +55,122 @@
"name": "Editor PDF",
"subtitle": "Anotar, resaltar, redactar, comentar, agregar formas/imágenes, buscar y ver PDFs."
},
+ "editPdfText": {
+ "name": "Editar texto PDF",
+ "subtitle": "Haz clic en cualquier párrafo y edítalo directamente con reajuste de texto en vivo, fuentes y estilos.",
+ "saved": "¡PDF guardado correctamente! ({{size}} KB)",
+ "failedToLoad": "No se pudo cargar el editor de texto PDF.",
+ "howToUpload": "Subir PDF",
+ "howToClick": "Haz clic en un párrafo",
+ "howToRestyle": "Cambia el estilo de todo",
+ "relatedPdfEditor": "Anota y resalta PDF",
+ "relatedSignPdf": "Dibuja, escribe o sube una firma",
+ "relatedWatermark": "Estampa texto o imágenes",
+ "relatedStamps": "Coloca sellos en las páginas",
+ "relatedCompress": "Reduce el tamaño del archivo",
+ "relatedOrganizeName": "Organizar PDF",
+ "relatedOrganize": "Reordena y gestiona páginas",
+ "open": "Abrir",
+ "export": "Exportar",
+ "filterAll": "Todo",
+ "filterText": "Texto",
+ "filterImages": "Imágenes",
+ "filterShapes": "Formas",
+ "addText": "Añadir texto",
+ "langAuto": "Automático",
+ "change": "Cambiar",
+ "ignore": "Ignorar",
+ "next": "Siguiente",
+ "replace": "Reemplazar",
+ "replaceAll": "Reemplazar todo",
+ "emptyState": "Abre un PDF para editar su texto, imágenes y formas.",
+ "choosePdf": "Elegir un PDF…",
+ "font": "Fuente",
+ "systemFonts": "Fuentes del sistema",
+ "color": "Color",
+ "alignment": "Alineación",
+ "style": "Estilo",
+ "lists": "Listas",
+ "outline": "Contorno",
+ "spacing": "Espaciado",
+ "direction": "Dirección",
+ "dirAuto": "Automático",
+ "pathStroke": "Contorno del trazado",
+ "tipBackToUpload": "Volver a la subida",
+ "tipOpenAnotherPdf": "Abrir otro PDF",
+ "tipExportS": "Exportar (⌘S)",
+ "tipUndoZ": "Deshacer (⌘Z)",
+ "tipRedoZ": "Rehacer (⇧⌘Z)",
+ "tipFileDetails": "Detalles del archivo",
+ "tipEditExistingContent": "Editar contenido existente",
+ "tipWhatTheEditToolTouchesNarrowTheScopeOnBus": "Qué modifica la herramienta Editar: limita el alcance en páginas con mucho contenido",
+ "tipClickThePageToAddATextBox": "Haz clic en la página para añadir un cuadro de texto",
+ "tipSpellCheckLanguage": "Idioma del corrector ortográfico",
+ "tipFindAndReplace": "Buscar y reemplazar",
+ "tipAddAnImage": "Añadir una imagen",
+ "tipCopyThisPageSTextInReadingOrder": "Copiar el texto de esta página en orden de lectura",
+ "tipCheckTheSpellingOfThisPage": "Revisar la ortografía de esta página",
+ "tipShowOrHideTheFormatPanel": "Mostrar u ocultar el panel de formato",
+ "phReplaceWith": "Reemplazar con",
+ "tipAddToYourDictionary": "Añadir al diccionario",
+ "tipClose": "Cerrar",
+ "phFind": "Buscar",
+ "tipPreviousMatch": "Coincidencia anterior",
+ "tipNextMatch": "Coincidencia siguiente",
+ "tipRotateLeft90": "Girar 90° a la izquierda",
+ "tipRotateRight90": "Girar 90° a la derecha",
+ "tipFlipHorizontal": "Voltear horizontalmente",
+ "tipFlipVertical": "Voltear verticalmente",
+ "tipReplaceThisImageKeepingItsPlacement": "Reemplazar esta imagen conservando su posición",
+ "tipAlternateTextForAccessibilityTaggedPdfs": "Texto alternativo para accesibilidad (PDF etiquetados)",
+ "tipBringToFront": "Traer al frente",
+ "tipSendToBack": "Enviar al fondo",
+ "tipDuplicate": "Duplicar",
+ "tipDelete": "Eliminar",
+ "tipAlignLeftEdges": "Alinear bordes izquierdos",
+ "tipAlignHorizontalCenters": "Centrar horizontalmente",
+ "tipAlignRightEdges": "Alinear bordes derechos",
+ "tipAlignTops": "Alinear bordes superiores",
+ "tipAlignVerticalCenters": "Centrar verticalmente",
+ "tipAlignBottoms": "Alinear bordes inferiores",
+ "tipDistributeHorizontally": "Distribuir horizontalmente",
+ "tipDistributeVertically": "Distribuir verticalmente",
+ "tipFontSize": "Tamaño de fuente",
+ "tipListFontsInstalledOnThisDeviceChromium": "Listar las fuentes instaladas en este dispositivo (Chromium)",
+ "tipCustomTextColor": "Color de texto personalizado",
+ "tipAlignLeft": "Alinear a la izquierda",
+ "tipAlignCenter": "Centrar",
+ "tipAlignRight": "Alinear a la derecha",
+ "tipJustify": "Justificar",
+ "tipBold": "Negrita",
+ "tipItalic": "Cursiva",
+ "tipUnderline": "Subrayado",
+ "tipStrikethrough": "Tachado",
+ "tipSuperscript": "Superíndice",
+ "tipSubscript": "Subíndice",
+ "tipToggleBulletedItem": "Alternar viñeta",
+ "tipToggleNumberedItem": "Alternar elemento numerado",
+ "tipNumberedListMarkerStyle": "Estilo de marcador de listas numeradas",
+ "tipDecreaseListIndentTab": "Reducir sangría (⇧Tab)",
+ "tipIncreaseListIndentTab": "Aumentar sangría (Tab)",
+ "tipTextOutlineColor": "Color del contorno del texto",
+ "tipOutlineWidth0None": "Grosor del contorno (0 = ninguno)",
+ "tipFromContent": "Del contenido",
+ "tipLeftToRight": "De izquierda a derecha",
+ "tipRightToLeft": "De derecha a izquierda",
+ "tipStrokeColor": "Color del trazo",
+ "tipStrokeWidth": "Grosor del trazo",
+ "tipZoomPresets": "Ajustes de zoom",
+ "tipZoomOut": "Alejar",
+ "tipZoomIn": "Acercar",
+ "tipFitPage": "Ajustar página",
+ "tipPreviousPage": "Página anterior",
+ "tipTypeAPageNumberAndPressEnter": "Escribe un número de página y pulsa Intro",
+ "tipNextPage": "Página siguiente",
+ "tipDocumentProperties": "Propiedades del documento",
+ "tipHideBar": "Ocultar barra",
+ "tipShowBar": "Mostrar barra"
+ },
"jpgToPdf": {
"name": "JPG a PDF",
"subtitle": "Crea un PDF desde imágenes JPG, JPEG y JPEG2000 (JP2/JPX)."
@@ -514,6 +630,51 @@
"retainPageLabels": "Conservar las etiquetas de página originales de cada archivo",
"retainPageLabelsHint": "Desactivado (predeterminado): el PDF combinado usa numeración natural 1–N. Activado: se conservan las etiquetas de página originales de cada archivo (puede producir etiquetas duplicadas)."
},
+ "duplexCollate": {
+ "name": "Intercalar escaneo a doble cara",
+ "subtitle": "Reordena un PDF escaneado en dos bloques (primero todos los anversos, luego todos los reversos) en el orden anverso/reverso correcto.",
+ "totalPagesLabel": "Páginas totales:",
+ "splitAfterPageLabel": "Dividir después de la página",
+ "splitAfterPagePlaceholder": "p. ej. 50",
+ "backBlockOrderLabel": "Orden del bloque de reversos",
+ "backBlockReverseOption": "Invertir el bloque de reversos (habitual si se giró la pila)",
+ "backBlockKeepOption": "Mantener el bloque de reversos tal cual",
+ "autoSplitButton": "Detectar división automáticamente (mitad)",
+ "exportGroupedLabel": "Exportar archivos agrupados según la longitud del documento original",
+ "pagesPerDocumentLabel": "Páginas por documento original",
+ "groupedHelp": "Si se activa, el resultado es un ZIP con un PDF por cada documento agrupado.",
+ "collateButton": "Intercalar PDF",
+ "invalidFileTitle": "Archivo no válido",
+ "invalidFileMessage": "Selecciona un archivo PDF.",
+ "loadingPdf": "Cargando PDF...",
+ "loadErrorMessage": "No se pudo cargar el archivo PDF.",
+ "fileMeta": "{{size}} - {{count}} páginas",
+ "oddPageWarning": "Número de páginas impar ({{count}}). Un escaneo a doble cara debería tener un número par de páginas; puede que falte una página o sobre una.",
+ "previewSummary": "Bloque de anversos: {{frontCount}} página(s), bloque de reversos: {{backCount}} página(s). Resultado: {{outputCount}} página(s), en secuencia anverso/reverso.",
+ "previewMismatchWarning": "Los bloques de anversos y reversos tienen longitudes distintas ({{frontCount}} anversos frente a {{backCount}} reversos). Las {{extraCount}} página(s) sobrantes del bloque más largo se añadirán sin pareja al final.",
+ "missingFileTitle": "Falta el archivo",
+ "missingFileMessage": "Sube un PDF con al menos 2 páginas antes de procesarlo.",
+ "invalidSplitTitle": "Punto de división no válido",
+ "invalidSplitMessage": "El punto de división debe estar entre la página 1 y la página {{maxSplit}}.",
+ "unevenConfirmTitle": "Bloques desiguales",
+ "unevenConfirmMessage": "El bloque de anversos tiene {{frontCount}} página(s) y el de reversos {{backCount}} página(s).\nNormalmente esto significa que una página se escaneó dos veces o que falta alguna.\n\nLas {{extraCount}} página(s) sin pareja se añadirán al final.\n\n¿Continuar de todos modos?",
+ "invalidGroupTitle": "Tamaño de grupo no válido",
+ "invalidGroupMessage": "Introduce un número válido de páginas por documento original.",
+ "collatingLoader": "Intercalando el escaneo a doble cara...",
+ "successMessage": "PDF intercalado generado correctamente.",
+ "successGroupedMessage": "Intercalado completado. Se han generado {{fileCount}} archivo(s) agrupado(s).",
+ "processErrorMessage": "No se pudo intercalar el PDF.",
+ "howItWorksStep1Title": "Sube el escaneo",
+ "howItWorksStep1": "Suelta el PDF único que contiene todos los anversos seguidos de todos los reversos.",
+ "howItWorksStep2Title": "Define el punto de división",
+ "howItWorksStep2": "Indica dónde termina el bloque de anversos y elige si el bloque de reversos debe invertirse.",
+ "howItWorksStep3Title": "Intercala y descarga",
+ "howItWorksStep3": "Obtén un PDF intercalado o un ZIP con un archivo independiente por cada documento original.",
+ "faqOddPagesQuestion": "¿Y si mi escaneo tiene un número impar de páginas?",
+ "faqOddPagesAnswer": "Un escaneo a doble cara debería tener un número par de páginas. Si no es así, probablemente se escaneó una página dos veces o se omitió alguna. La herramienta te avisa antes de procesar y las páginas sin pareja se añaden al final para que no se pierda nada.",
+ "faqBackOrderQuestion": "¿Por qué querría mantener el bloque de reversos tal cual?",
+ "faqBackOrderAnswer": "Depende de cómo se colocó la pila en la segunda pasada. Girar la pila entera produce reversos en orden inverso, que es la opción predeterminada. Si tu escáner los devolvió en el mismo orden que los anversos, elige mantener el bloque tal cual."
+ },
"addAttachments": {
"name": "Agregar Adjuntos",
"subtitle": "Incrusta uno o más archivos en tu PDF."
diff --git a/public/locales/fr/tools.json b/public/locales/fr/tools.json
index 35f40ae5..f502ef7f 100644
--- a/public/locales/fr/tools.json
+++ b/public/locales/fr/tools.json
@@ -55,6 +55,122 @@
"name": "Éditeur PDF",
"subtitle": "Annoter, surligner, masquer, commenter, ajouter des formes ou images, rechercher et afficher des PDF."
},
+ "editPdfText": {
+ "name": "Modifier le texte d'un PDF",
+ "subtitle": "Cliquer sur un paragraphe et le modifier directement, avec remise en page instantanée, polices et styles.",
+ "saved": "PDF enregistré avec succès ! ({{size}} Ko)",
+ "failedToLoad": "Impossible de charger l'éditeur de texte PDF.",
+ "howToUpload": "Importer un PDF",
+ "howToClick": "Cliquer sur un paragraphe",
+ "howToRestyle": "Tout restyler",
+ "relatedPdfEditor": "Annoter et surligner des PDF",
+ "relatedSignPdf": "Dessiner, saisir ou importer une signature",
+ "relatedWatermark": "Apposer du texte ou des images",
+ "relatedStamps": "Placer des tampons sur les pages",
+ "relatedCompress": "Réduire la taille du fichier",
+ "relatedOrganizeName": "Organiser le PDF",
+ "relatedOrganize": "Réorganiser et gérer les pages",
+ "open": "Ouvrir",
+ "export": "Exporter",
+ "filterAll": "Tout",
+ "filterText": "Texte",
+ "filterImages": "Images",
+ "filterShapes": "Formes",
+ "addText": "Ajouter du texte",
+ "langAuto": "Auto",
+ "change": "Modifier",
+ "ignore": "Ignorer",
+ "next": "Suivant",
+ "replace": "Remplacer",
+ "replaceAll": "Tout remplacer",
+ "emptyState": "Ouvrez un PDF pour en modifier le texte, les images et les formes.",
+ "choosePdf": "Choisir un PDF…",
+ "font": "Police",
+ "systemFonts": "Polices système",
+ "color": "Couleur",
+ "alignment": "Alignement",
+ "style": "Style",
+ "lists": "Listes",
+ "outline": "Contour",
+ "spacing": "Espacement",
+ "direction": "Direction",
+ "dirAuto": "Auto",
+ "pathStroke": "Contour du tracé",
+ "tipBackToUpload": "Retour à l'import",
+ "tipOpenAnotherPdf": "Ouvrir un autre PDF",
+ "tipExportS": "Exporter (⌘S)",
+ "tipUndoZ": "Annuler (⌘Z)",
+ "tipRedoZ": "Rétablir (⇧⌘Z)",
+ "tipFileDetails": "Détails du fichier",
+ "tipEditExistingContent": "Modifier le contenu existant",
+ "tipWhatTheEditToolTouchesNarrowTheScopeOnBus": "Ce que l'outil Modifier affecte — restreindre la portée sur les pages chargées",
+ "tipClickThePageToAddATextBox": "Cliquez sur la page pour ajouter une zone de texte",
+ "tipSpellCheckLanguage": "Langue du correcteur orthographique",
+ "tipFindAndReplace": "Rechercher et remplacer",
+ "tipAddAnImage": "Ajouter une image",
+ "tipCopyThisPageSTextInReadingOrder": "Copier le texte de cette page dans l'ordre de lecture",
+ "tipCheckTheSpellingOfThisPage": "Vérifier l'orthographe de cette page",
+ "tipShowOrHideTheFormatPanel": "Afficher ou masquer le panneau de mise en forme",
+ "phReplaceWith": "Remplacer par",
+ "tipAddToYourDictionary": "Ajouter au dictionnaire",
+ "tipClose": "Fermer",
+ "phFind": "Rechercher",
+ "tipPreviousMatch": "Occurrence précédente",
+ "tipNextMatch": "Occurrence suivante",
+ "tipRotateLeft90": "Rotation 90° à gauche",
+ "tipRotateRight90": "Rotation 90° à droite",
+ "tipFlipHorizontal": "Miroir horizontal",
+ "tipFlipVertical": "Miroir vertical",
+ "tipReplaceThisImageKeepingItsPlacement": "Remplacer cette image en conservant sa position",
+ "tipAlternateTextForAccessibilityTaggedPdfs": "Texte alternatif pour l'accessibilité (PDF balisés)",
+ "tipBringToFront": "Mettre au premier plan",
+ "tipSendToBack": "Mettre à l'arrière-plan",
+ "tipDuplicate": "Dupliquer",
+ "tipDelete": "Supprimer",
+ "tipAlignLeftEdges": "Aligner les bords gauches",
+ "tipAlignHorizontalCenters": "Centrer horizontalement",
+ "tipAlignRightEdges": "Aligner les bords droits",
+ "tipAlignTops": "Aligner les bords supérieurs",
+ "tipAlignVerticalCenters": "Centrer verticalement",
+ "tipAlignBottoms": "Aligner les bords inférieurs",
+ "tipDistributeHorizontally": "Répartir horizontalement",
+ "tipDistributeVertically": "Répartir verticalement",
+ "tipFontSize": "Taille de police",
+ "tipListFontsInstalledOnThisDeviceChromium": "Lister les polices installées sur cet appareil (Chromium)",
+ "tipCustomTextColor": "Couleur de texte personnalisée",
+ "tipAlignLeft": "Aligner à gauche",
+ "tipAlignCenter": "Centrer",
+ "tipAlignRight": "Aligner à droite",
+ "tipJustify": "Justifier",
+ "tipBold": "Gras",
+ "tipItalic": "Italique",
+ "tipUnderline": "Souligné",
+ "tipStrikethrough": "Barré",
+ "tipSuperscript": "Exposant",
+ "tipSubscript": "Indice",
+ "tipToggleBulletedItem": "Activer la puce",
+ "tipToggleNumberedItem": "Activer la numérotation",
+ "tipNumberedListMarkerStyle": "Style de puce des listes numérotées",
+ "tipDecreaseListIndentTab": "Diminuer le retrait (⇧Tab)",
+ "tipIncreaseListIndentTab": "Augmenter le retrait (Tab)",
+ "tipTextOutlineColor": "Couleur du contour du texte",
+ "tipOutlineWidth0None": "Épaisseur du contour (0 = aucune)",
+ "tipFromContent": "À partir du contenu",
+ "tipLeftToRight": "De gauche à droite",
+ "tipRightToLeft": "De droite à gauche",
+ "tipStrokeColor": "Couleur du trait",
+ "tipStrokeWidth": "Épaisseur du trait",
+ "tipZoomPresets": "Préréglages de zoom",
+ "tipZoomOut": "Zoom arrière",
+ "tipZoomIn": "Zoom avant",
+ "tipFitPage": "Ajuster à la page",
+ "tipPreviousPage": "Page précédente",
+ "tipTypeAPageNumberAndPressEnter": "Saisissez un numéro de page et appuyez sur Entrée",
+ "tipNextPage": "Page suivante",
+ "tipDocumentProperties": "Propriétés du document",
+ "tipHideBar": "Masquer la barre",
+ "tipShowBar": "Afficher la barre"
+ },
"jpgToPdf": {
"name": "JPG vers PDF",
"subtitle": "Créer un PDF à partir d’une ou plusieurs images JPG."
@@ -514,6 +630,51 @@
"retainPageLabels": "Conserver les étiquettes de page d'origine de chaque fichier",
"retainPageLabelsHint": "Désactivé (par défaut) : le PDF fusionné utilise une numérotation naturelle 1–N. Activé : les étiquettes de page d'origine de chaque fichier sont conservées (peut produire des étiquettes en double)."
},
+ "duplexCollate": {
+ "name": "Assembler un scan recto/verso",
+ "subtitle": "Réorganiser un PDF numérisé en deux blocs (tous les rectos, puis tous les versos) dans le bon ordre recto/verso.",
+ "totalPagesLabel": "Pages au total :",
+ "splitAfterPageLabel": "Séparer après la page",
+ "splitAfterPagePlaceholder": "ex. 50",
+ "backBlockOrderLabel": "Ordre du bloc verso",
+ "backBlockReverseOption": "Inverser le bloc verso (courant si la pile a été retournée)",
+ "backBlockKeepOption": "Conserver le bloc verso tel quel",
+ "autoSplitButton": "Détecter la séparation (moitié)",
+ "exportGroupedLabel": "Exporter des fichiers groupés selon la longueur du document d'origine",
+ "pagesPerDocumentLabel": "Pages par document d'origine",
+ "groupedHelp": "Si cette option est activée, la sortie est une archive ZIP contenant un PDF par document groupé.",
+ "collateButton": "Assembler le PDF",
+ "invalidFileTitle": "Fichier invalide",
+ "invalidFileMessage": "Veuillez sélectionner un fichier PDF.",
+ "loadingPdf": "Chargement du PDF...",
+ "loadErrorMessage": "Échec du chargement du fichier PDF.",
+ "fileMeta": "{{size}} - {{count}} pages",
+ "oddPageWarning": "Nombre de pages impair ({{count}}). Un scan recto/verso doit comporter un nombre pair de pages ; il manque peut-être une page ou il y en a une en trop.",
+ "previewSummary": "Bloc recto : {{frontCount}} page(s), bloc verso : {{backCount}} page(s). Sortie : {{outputCount}} page(s), en séquence recto/verso.",
+ "previewMismatchWarning": "Les blocs recto et verso n'ont pas la même longueur ({{frontCount}} rectos contre {{backCount}} versos). Les {{extraCount}} page(s) en trop du bloc le plus long seront ajoutées sans paire à la fin.",
+ "missingFileTitle": "Fichier manquant",
+ "missingFileMessage": "Veuillez importer un PDF d'au moins 2 pages avant de lancer le traitement.",
+ "invalidSplitTitle": "Point de séparation invalide",
+ "invalidSplitMessage": "Le point de séparation doit être compris entre la page 1 et la page {{maxSplit}}.",
+ "unevenConfirmTitle": "Blocs inégaux",
+ "unevenConfirmMessage": "Le bloc recto contient {{frontCount}} page(s) et le bloc verso {{backCount}} page(s).\nCela signifie généralement qu'une page a été numérisée deux fois ou qu'il en manque une.\n\nLes {{extraCount}} page(s) sans paire seront ajoutées à la fin.\n\nContinuer quand même ?",
+ "invalidGroupTitle": "Taille de groupe invalide",
+ "invalidGroupMessage": "Veuillez saisir un nombre de pages valide par document d'origine.",
+ "collatingLoader": "Assemblage du scan recto/verso...",
+ "successMessage": "PDF assemblé généré avec succès.",
+ "successGroupedMessage": "Assemblage terminé. {{fileCount}} fichier(s) groupé(s) généré(s).",
+ "processErrorMessage": "Échec de l'assemblage du PDF.",
+ "howItWorksStep1Title": "Importer le scan",
+ "howItWorksStep1": "Déposez le PDF unique qui contient tous les rectos, suivis de tous les versos.",
+ "howItWorksStep2Title": "Définir le point de séparation",
+ "howItWorksStep2": "Indiquez où se termine le bloc recto, puis choisissez si le bloc verso doit être inversé.",
+ "howItWorksStep3Title": "Assembler et télécharger",
+ "howItWorksStep3": "Obtenez un PDF intercalé, ou une archive ZIP avec un fichier distinct par document d'origine.",
+ "faqOddPagesQuestion": "Que faire si mon scan a un nombre de pages impair ?",
+ "faqOddPagesAnswer": "Un scan recto/verso doit avoir un nombre pair de pages. Si ce n'est pas le cas, une page a probablement été numérisée deux fois ou oubliée. L'outil vous avertit avant le traitement, et les pages sans paire sont ajoutées à la fin afin que rien ne soit perdu.",
+ "faqBackOrderQuestion": "Pourquoi conserverais-je le bloc verso tel quel ?",
+ "faqBackOrderAnswer": "Cela dépend de la façon dont la pile a été insérée pour le second passage. Retourner la pile entière produit des versos en ordre inverse, ce qui correspond au réglage par défaut. Si votre scanner les a restitués dans le même ordre que les rectos, choisissez de conserver le bloc tel quel."
+ },
"addAttachments": {
"name": "Ajouter des pièces jointes",
"subtitle": "Intégrer un ou plusieurs fichiers dans le PDF."
diff --git a/public/locales/id/common.json b/public/locales/id/common.json
index bd528535..d1756681 100644
--- a/public/locales/id/common.json
+++ b/public/locales/id/common.json
@@ -216,7 +216,7 @@
},
"analytics": {
"question": "Apakah Anda menggunakan cookie atau analitik untuk melacak saya?",
- "answer": "Kami peduli dengan privasi Anda. BentoPDF tidak melacak informasi pribadi. Kami menggunakan Simple Analytics hanya untuk melihat jumlah kunjungan anonim. Ini berarti kami dapat mengetahui berapa banyak pengguna yang mengunjungi situs kami, tetapi kami tidak pernah tahu siapa Anda. Simple Analytics sepenuhnya patuh GDPR dan menghormati privasi Anda."
+ "answer": "Tidak. BentoPDF sama sekali tidak melacak Anda. Kami tidak menggunakan cookie, analitik, atau skrip pelacakan apa pun, bahkan di situs web langsung bentopdf.com. Kami tidak pernah tahu siapa Anda atau apa yang Anda lakukan dengan alat-alat ini."
}
},
"testimonials": {
diff --git a/public/locales/id/tools.json b/public/locales/id/tools.json
index 4daf1553..2a5caf9e 100644
--- a/public/locales/id/tools.json
+++ b/public/locales/id/tools.json
@@ -55,6 +55,122 @@
"name": "Editor PDF",
"subtitle": "Anotasi, sorot, redaksi, komentar, tambah bentuk/gambar, cari, dan lihat PDF."
},
+ "editPdfText": {
+ "name": "Edit Teks PDF",
+ "subtitle": "Klik paragraf mana pun dan edit langsung di tempat dengan penyusunan ulang teks secara langsung, font, dan gaya.",
+ "saved": "PDF berhasil disimpan! ({{size}} KB)",
+ "failedToLoad": "Gagal memuat Editor Teks PDF.",
+ "howToUpload": "Unggah PDF",
+ "howToClick": "Klik sebuah paragraf",
+ "howToRestyle": "Ubah gaya apa pun",
+ "relatedPdfEditor": "Beri anotasi dan tandai PDF",
+ "relatedSignPdf": "Gambar, ketik, atau unggah tanda tangan",
+ "relatedWatermark": "Bubuhkan teks atau gambar",
+ "relatedStamps": "Tempatkan stempel di halaman",
+ "relatedCompress": "Perkecil ukuran file",
+ "relatedOrganizeName": "Atur PDF",
+ "relatedOrganize": "Susun ulang dan kelola halaman",
+ "open": "Buka",
+ "export": "Ekspor",
+ "filterAll": "Semua",
+ "filterText": "Teks",
+ "filterImages": "Gambar",
+ "filterShapes": "Bentuk",
+ "addText": "Tambah teks",
+ "langAuto": "Otomatis",
+ "change": "Ubah",
+ "ignore": "Abaikan",
+ "next": "Berikutnya",
+ "replace": "Ganti",
+ "replaceAll": "Ganti semua",
+ "emptyState": "Buka PDF untuk mengedit teks, gambar, dan bentuknya.",
+ "choosePdf": "Pilih PDF…",
+ "font": "Font",
+ "systemFonts": "Font sistem",
+ "color": "Warna",
+ "alignment": "Perataan",
+ "style": "Gaya",
+ "lists": "Daftar",
+ "outline": "Garis luar",
+ "spacing": "Jarak",
+ "direction": "Arah",
+ "dirAuto": "Otomatis",
+ "pathStroke": "Goresan jalur",
+ "tipBackToUpload": "Kembali ke unggahan",
+ "tipOpenAnotherPdf": "Buka PDF lain",
+ "tipExportS": "Ekspor (⌘S)",
+ "tipUndoZ": "Urungkan (⌘Z)",
+ "tipRedoZ": "Ulangi (⇧⌘Z)",
+ "tipFileDetails": "Detail file",
+ "tipEditExistingContent": "Edit konten yang ada",
+ "tipWhatTheEditToolTouchesNarrowTheScopeOnBus": "Yang dipengaruhi alat Edit — persempit cakupan pada halaman yang padat",
+ "tipClickThePageToAddATextBox": "Klik halaman untuk menambahkan kotak teks",
+ "tipSpellCheckLanguage": "Bahasa pemeriksa ejaan",
+ "tipFindAndReplace": "Cari dan ganti",
+ "tipAddAnImage": "Tambahkan gambar",
+ "tipCopyThisPageSTextInReadingOrder": "Salin teks halaman ini sesuai urutan baca",
+ "tipCheckTheSpellingOfThisPage": "Periksa ejaan halaman ini",
+ "tipShowOrHideTheFormatPanel": "Tampilkan atau sembunyikan panel format",
+ "phReplaceWith": "Ganti dengan",
+ "tipAddToYourDictionary": "Tambahkan ke kamus",
+ "tipClose": "Tutup",
+ "phFind": "Cari",
+ "tipPreviousMatch": "Kecocokan sebelumnya",
+ "tipNextMatch": "Kecocokan berikutnya",
+ "tipRotateLeft90": "Putar 90° ke kiri",
+ "tipRotateRight90": "Putar 90° ke kanan",
+ "tipFlipHorizontal": "Balik horizontal",
+ "tipFlipVertical": "Balik vertikal",
+ "tipReplaceThisImageKeepingItsPlacement": "Ganti gambar ini dengan mempertahankan posisinya",
+ "tipAlternateTextForAccessibilityTaggedPdfs": "Teks alternatif untuk aksesibilitas (PDF bertag)",
+ "tipBringToFront": "Bawa ke depan",
+ "tipSendToBack": "Kirim ke belakang",
+ "tipDuplicate": "Duplikat",
+ "tipDelete": "Hapus",
+ "tipAlignLeftEdges": "Ratakan tepi kiri",
+ "tipAlignHorizontalCenters": "Pusatkan horizontal",
+ "tipAlignRightEdges": "Ratakan tepi kanan",
+ "tipAlignTops": "Ratakan tepi atas",
+ "tipAlignVerticalCenters": "Pusatkan vertikal",
+ "tipAlignBottoms": "Ratakan tepi bawah",
+ "tipDistributeHorizontally": "Distribusikan horizontal",
+ "tipDistributeVertically": "Distribusikan vertikal",
+ "tipFontSize": "Ukuran font",
+ "tipListFontsInstalledOnThisDeviceChromium": "Daftar font yang terpasang di perangkat ini (Chromium)",
+ "tipCustomTextColor": "Warna teks khusus",
+ "tipAlignLeft": "Rata kiri",
+ "tipAlignCenter": "Rata tengah",
+ "tipAlignRight": "Rata kanan",
+ "tipJustify": "Rata kanan-kiri",
+ "tipBold": "Tebal",
+ "tipItalic": "Miring",
+ "tipUnderline": "Garis bawah",
+ "tipStrikethrough": "Coret",
+ "tipSuperscript": "Superskrip",
+ "tipSubscript": "Subskrip",
+ "tipToggleBulletedItem": "Alihkan butir",
+ "tipToggleNumberedItem": "Alihkan item bernomor",
+ "tipNumberedListMarkerStyle": "Gaya penanda daftar bernomor",
+ "tipDecreaseListIndentTab": "Kurangi indentasi (⇧Tab)",
+ "tipIncreaseListIndentTab": "Tambah indentasi (Tab)",
+ "tipTextOutlineColor": "Warna garis luar teks",
+ "tipOutlineWidth0None": "Lebar garis luar (0 = tidak ada)",
+ "tipFromContent": "Dari konten",
+ "tipLeftToRight": "Kiri ke kanan",
+ "tipRightToLeft": "Kanan ke kiri",
+ "tipStrokeColor": "Warna goresan",
+ "tipStrokeWidth": "Lebar goresan",
+ "tipZoomPresets": "Prasetel zoom",
+ "tipZoomOut": "Perkecil",
+ "tipZoomIn": "Perbesar",
+ "tipFitPage": "Paskan halaman",
+ "tipPreviousPage": "Halaman sebelumnya",
+ "tipTypeAPageNumberAndPressEnter": "Ketik nomor halaman lalu tekan Enter",
+ "tipNextPage": "Halaman berikutnya",
+ "tipDocumentProperties": "Properti dokumen",
+ "tipHideBar": "Sembunyikan bilah",
+ "tipShowBar": "Tampilkan bilah"
+ },
"jpgToPdf": {
"name": "JPG ke PDF",
"subtitle": "Buat PDF dari gambar JPG, JPEG, dan JPEG2000 (JP2/JPX)."
@@ -514,6 +630,51 @@
"retainPageLabels": "Pertahankan label halaman asli dari setiap file",
"retainPageLabelsHint": "Mati (default): PDF yang digabungkan menggunakan penomoran halaman alami 1–N. Aktif: label halaman asli setiap file dipertahankan (dapat menghasilkan label ganda)."
},
+ "duplexCollate": {
+ "name": "Susun Ulang Pindaian Dupleks",
+ "subtitle": "Susun ulang PDF yang dipindai dalam dua blok (semua halaman depan, lalu semua halaman belakang) menjadi urutan depan/belakang yang benar.",
+ "totalPagesLabel": "Total halaman:",
+ "splitAfterPageLabel": "Pisahkan setelah halaman",
+ "splitAfterPagePlaceholder": "mis. 50",
+ "backBlockOrderLabel": "Urutan blok halaman belakang",
+ "backBlockReverseOption": "Balik blok halaman belakang (umum jika tumpukan dibalik)",
+ "backBlockKeepOption": "Biarkan blok halaman belakang apa adanya",
+ "autoSplitButton": "Deteksi pemisahan otomatis (setengah)",
+ "exportGroupedLabel": "Ekspor file yang dikelompokkan sesuai panjang dokumen asli",
+ "pagesPerDocumentLabel": "Halaman per dokumen asli",
+ "groupedHelp": "Jika diaktifkan, hasilnya berupa ZIP berisi satu PDF untuk setiap dokumen yang dikelompokkan.",
+ "collateButton": "Susun PDF",
+ "invalidFileTitle": "File Tidak Valid",
+ "invalidFileMessage": "Silakan pilih file PDF.",
+ "loadingPdf": "Memuat PDF...",
+ "loadErrorMessage": "Gagal memuat file PDF.",
+ "fileMeta": "{{size}} - {{count}} halaman",
+ "oddPageWarning": "Jumlah halaman ganjil ({{count}}). Pindaian dupleks seharusnya berjumlah genap; mungkin ada halaman yang hilang atau berlebih.",
+ "previewSummary": "Blok depan: {{frontCount}} halaman, blok belakang: {{backCount}} halaman. Hasil: {{outputCount}} halaman, dalam urutan depan/belakang.",
+ "previewMismatchWarning": "Panjang blok depan dan belakang berbeda ({{frontCount}} depan vs {{backCount}} belakang). {{extraCount}} halaman berlebih pada sisi yang lebih panjang akan ditambahkan tanpa pasangan di akhir.",
+ "missingFileTitle": "File Tidak Ada",
+ "missingFileMessage": "Unggah PDF dengan minimal 2 halaman sebelum diproses.",
+ "invalidSplitTitle": "Titik Pemisah Tidak Valid",
+ "invalidSplitMessage": "Titik pemisah harus antara halaman 1 dan halaman {{maxSplit}}.",
+ "unevenConfirmTitle": "Blok Tidak Seimbang",
+ "unevenConfirmMessage": "Blok depan memiliki {{frontCount}} halaman dan blok belakang {{backCount}} halaman.\nBiasanya ini berarti ada halaman yang terpindai dua kali atau hilang.\n\n{{extraCount}} halaman tanpa pasangan akan ditambahkan di akhir.\n\nTetap lanjutkan?",
+ "invalidGroupTitle": "Ukuran Grup Tidak Valid",
+ "invalidGroupMessage": "Masukkan jumlah halaman per dokumen asli yang valid.",
+ "collatingLoader": "Menyusun pindaian dupleks...",
+ "successMessage": "PDF tersusun berhasil dibuat.",
+ "successGroupedMessage": "Penyusunan selesai. {{fileCount}} file kelompok dibuat.",
+ "processErrorMessage": "Gagal menyusun PDF.",
+ "howItWorksStep1Title": "Unggah hasil pindai",
+ "howItWorksStep1": "Letakkan satu PDF yang berisi semua halaman depan diikuti semua halaman belakang.",
+ "howItWorksStep2Title": "Tentukan titik pemisah",
+ "howItWorksStep2": "Tandai di mana blok depan berakhir, lalu pilih apakah blok belakang perlu dibalik.",
+ "howItWorksStep3Title": "Susun dan unduh",
+ "howItWorksStep3": "Dapatkan satu PDF dengan halaman berselang-seling, atau ZIP berisi file terpisah untuk setiap dokumen asli.",
+ "faqOddPagesQuestion": "Bagaimana jika jumlah halaman hasil pindai saya ganjil?",
+ "faqOddPagesAnswer": "Pindaian dupleks seharusnya berjumlah halaman genap. Jika tidak, kemungkinan ada halaman yang terpindai dua kali atau terlewat. Alat ini memperingatkan Anda sebelum memproses, dan halaman tanpa pasangan ditambahkan di akhir sehingga tidak ada yang hilang.",
+ "faqBackOrderQuestion": "Mengapa saya perlu membiarkan blok belakang apa adanya?",
+ "faqBackOrderAnswer": "Itu tergantung cara tumpukan dimasukkan pada pemindaian kedua. Membalik seluruh tumpukan menghasilkan halaman belakang dalam urutan terbalik, dan itulah pengaturan bawaannya. Jika pemindai Anda mengembalikannya dalam urutan yang sama dengan halaman depan, pilih untuk membiarkan blok apa adanya."
+ },
"addAttachments": {
"name": "Tambah Lampiran",
"subtitle": "Sematkan satu atau lebih file ke PDF Anda."
diff --git a/public/locales/it/common.json b/public/locales/it/common.json
index dfe5b60e..0718699e 100644
--- a/public/locales/it/common.json
+++ b/public/locales/it/common.json
@@ -216,7 +216,7 @@
},
"analytics": {
"question": "Usate cookie o analytics per tracciarmi?",
- "answer": "Ci teniamo alla tua privacy. BentoPDF non traccia informazioni personali. Usiamo Simple Analytics solo per visualizzare conteggi di visite anonime. Questo significa che possiamo sapere quante persone visitano il sito, ma non chi sei. Simple Analytics è pienamente conforme al GDPR e rispetta la tua privacy."
+ "answer": "No. BentoPDF non ti traccia in alcun modo. Non usiamo cookie, strumenti di analisi né script di tracciamento, nemmeno sul sito web live bentopdf.com. Non sappiamo mai chi sei né cosa fai con gli strumenti."
}
},
"testimonials": {
diff --git a/public/locales/it/tools.json b/public/locales/it/tools.json
index fce332f9..ded8cea7 100644
--- a/public/locales/it/tools.json
+++ b/public/locales/it/tools.json
@@ -55,6 +55,122 @@
"name": "Editor PDF",
"subtitle": "Annota, evidenzia, redigi, commenta, aggiungi forme/immagini, cerca e visualizza PDF."
},
+ "editPdfText": {
+ "name": "Modifica testo PDF",
+ "subtitle": "Clicca su un paragrafo qualsiasi e modificalo direttamente con ricomposizione del testo in tempo reale, caratteri e stili.",
+ "saved": "PDF salvato correttamente! ({{size}} KB)",
+ "failedToLoad": "Impossibile caricare l'editor di testo PDF.",
+ "howToUpload": "Carica PDF",
+ "howToClick": "Fai clic su un paragrafo",
+ "howToRestyle": "Ridefinisci lo stile",
+ "relatedPdfEditor": "Annota ed evidenzia i PDF",
+ "relatedSignPdf": "Disegna, digita o carica una firma",
+ "relatedWatermark": "Applica testo o immagini",
+ "relatedStamps": "Posiziona timbri sulle pagine",
+ "relatedCompress": "Riduci le dimensioni del file",
+ "relatedOrganizeName": "Organizza PDF",
+ "relatedOrganize": "Riordina e gestisci le pagine",
+ "open": "Apri",
+ "export": "Esporta",
+ "filterAll": "Tutto",
+ "filterText": "Testo",
+ "filterImages": "Immagini",
+ "filterShapes": "Forme",
+ "addText": "Aggiungi testo",
+ "langAuto": "Automatico",
+ "change": "Modifica",
+ "ignore": "Ignora",
+ "next": "Avanti",
+ "replace": "Sostituisci",
+ "replaceAll": "Sostituisci tutto",
+ "emptyState": "Apri un PDF per modificarne testo, immagini e forme.",
+ "choosePdf": "Scegli un PDF…",
+ "font": "Carattere",
+ "systemFonts": "Caratteri di sistema",
+ "color": "Colore",
+ "alignment": "Allineamento",
+ "style": "Stile",
+ "lists": "Elenchi",
+ "outline": "Contorno",
+ "spacing": "Spaziatura",
+ "direction": "Direzione",
+ "dirAuto": "Automatico",
+ "pathStroke": "Contorno del tracciato",
+ "tipBackToUpload": "Torna al caricamento",
+ "tipOpenAnotherPdf": "Apri un altro PDF",
+ "tipExportS": "Esporta (⌘S)",
+ "tipUndoZ": "Annulla (⌘Z)",
+ "tipRedoZ": "Ripristina (⇧⌘Z)",
+ "tipFileDetails": "Dettagli file",
+ "tipEditExistingContent": "Modifica contenuti esistenti",
+ "tipWhatTheEditToolTouchesNarrowTheScopeOnBus": "Cosa modifica lo strumento Modifica: restringi l'ambito nelle pagine affollate",
+ "tipClickThePageToAddATextBox": "Fai clic sulla pagina per aggiungere una casella di testo",
+ "tipSpellCheckLanguage": "Lingua del correttore ortografico",
+ "tipFindAndReplace": "Trova e sostituisci",
+ "tipAddAnImage": "Aggiungi un'immagine",
+ "tipCopyThisPageSTextInReadingOrder": "Copia il testo di questa pagina nell'ordine di lettura",
+ "tipCheckTheSpellingOfThisPage": "Controlla l'ortografia di questa pagina",
+ "tipShowOrHideTheFormatPanel": "Mostra o nascondi il pannello formato",
+ "phReplaceWith": "Sostituisci con",
+ "tipAddToYourDictionary": "Aggiungi al dizionario",
+ "tipClose": "Chiudi",
+ "phFind": "Trova",
+ "tipPreviousMatch": "Corrispondenza precedente",
+ "tipNextMatch": "Corrispondenza successiva",
+ "tipRotateLeft90": "Ruota di 90° a sinistra",
+ "tipRotateRight90": "Ruota di 90° a destra",
+ "tipFlipHorizontal": "Capovolgi orizzontalmente",
+ "tipFlipVertical": "Capovolgi verticalmente",
+ "tipReplaceThisImageKeepingItsPlacement": "Sostituisci questa immagine mantenendone la posizione",
+ "tipAlternateTextForAccessibilityTaggedPdfs": "Testo alternativo per l'accessibilità (PDF taggati)",
+ "tipBringToFront": "Porta in primo piano",
+ "tipSendToBack": "Porta in secondo piano",
+ "tipDuplicate": "Duplica",
+ "tipDelete": "Elimina",
+ "tipAlignLeftEdges": "Allinea i bordi sinistri",
+ "tipAlignHorizontalCenters": "Centra orizzontalmente",
+ "tipAlignRightEdges": "Allinea i bordi destri",
+ "tipAlignTops": "Allinea i bordi superiori",
+ "tipAlignVerticalCenters": "Centra verticalmente",
+ "tipAlignBottoms": "Allinea i bordi inferiori",
+ "tipDistributeHorizontally": "Distribuisci orizzontalmente",
+ "tipDistributeVertically": "Distribuisci verticalmente",
+ "tipFontSize": "Dimensione carattere",
+ "tipListFontsInstalledOnThisDeviceChromium": "Elenca i caratteri installati su questo dispositivo (Chromium)",
+ "tipCustomTextColor": "Colore testo personalizzato",
+ "tipAlignLeft": "Allinea a sinistra",
+ "tipAlignCenter": "Centra",
+ "tipAlignRight": "Allinea a destra",
+ "tipJustify": "Giustifica",
+ "tipBold": "Grassetto",
+ "tipItalic": "Corsivo",
+ "tipUnderline": "Sottolineato",
+ "tipStrikethrough": "Barrato",
+ "tipSuperscript": "Apice",
+ "tipSubscript": "Pedice",
+ "tipToggleBulletedItem": "Attiva/disattiva punto elenco",
+ "tipToggleNumberedItem": "Attiva/disattiva elemento numerato",
+ "tipNumberedListMarkerStyle": "Stile marcatore elenchi numerati",
+ "tipDecreaseListIndentTab": "Riduci rientro (⇧Tab)",
+ "tipIncreaseListIndentTab": "Aumenta rientro (Tab)",
+ "tipTextOutlineColor": "Colore contorno testo",
+ "tipOutlineWidth0None": "Spessore contorno (0 = nessuno)",
+ "tipFromContent": "Dal contenuto",
+ "tipLeftToRight": "Da sinistra a destra",
+ "tipRightToLeft": "Da destra a sinistra",
+ "tipStrokeColor": "Colore traccia",
+ "tipStrokeWidth": "Spessore traccia",
+ "tipZoomPresets": "Preimpostazioni zoom",
+ "tipZoomOut": "Riduci zoom",
+ "tipZoomIn": "Aumenta zoom",
+ "tipFitPage": "Adatta pagina",
+ "tipPreviousPage": "Pagina precedente",
+ "tipTypeAPageNumberAndPressEnter": "Digita un numero di pagina e premi Invio",
+ "tipNextPage": "Pagina successiva",
+ "tipDocumentProperties": "Proprietà del documento",
+ "tipHideBar": "Nascondi barra",
+ "tipShowBar": "Mostra barra"
+ },
"jpgToPdf": {
"name": "JPG in PDF",
"subtitle": "Crea un PDF da immagini JPG, JPEG e JPEG2000 (JP2/JPX)."
@@ -514,6 +630,51 @@
"retainPageLabels": "Mantieni le etichette di pagina originali di ogni file",
"retainPageLabelsHint": "Disattivato (predefinito): il PDF unito usa la numerazione naturale 1–N. Attivato: le etichette di pagina originali di ogni file vengono mantenute (può produrre etichette duplicate)."
},
+ "duplexCollate": {
+ "name": "Fascicola scansione fronte/retro",
+ "subtitle": "Riordina un PDF scansionato in due blocchi (prima tutti i fronti, poi tutti i retri) nel corretto ordine fronte/retro.",
+ "totalPagesLabel": "Pagine totali:",
+ "splitAfterPageLabel": "Dividi dopo la pagina",
+ "splitAfterPagePlaceholder": "es. 50",
+ "backBlockOrderLabel": "Ordine del blocco dei retri",
+ "backBlockReverseOption": "Inverti il blocco dei retri (comune se la pila è stata girata)",
+ "backBlockKeepOption": "Mantieni il blocco dei retri così com'è",
+ "autoSplitButton": "Rileva divisione automaticamente (metà)",
+ "exportGroupedLabel": "Esporta file raggruppati in base alla lunghezza del documento originale",
+ "pagesPerDocumentLabel": "Pagine per documento originale",
+ "groupedHelp": "Se attivo, il risultato è un file ZIP contenente un PDF per ogni documento raggruppato.",
+ "collateButton": "Fascicola PDF",
+ "invalidFileTitle": "File non valido",
+ "invalidFileMessage": "Seleziona un file PDF.",
+ "loadingPdf": "Caricamento del PDF...",
+ "loadErrorMessage": "Impossibile caricare il file PDF.",
+ "fileMeta": "{{size}} - {{count}} pagine",
+ "oddPageWarning": "Numero di pagine dispari ({{count}}). Una scansione fronte/retro dovrebbe avere un numero pari di pagine: potrebbe mancarne una o essercene una in più.",
+ "previewSummary": "Blocco fronti: {{frontCount}} pagina/e, blocco retri: {{backCount}} pagina/e. Risultato: {{outputCount}} pagina/e, in sequenza fronte/retro.",
+ "previewMismatchWarning": "I blocchi fronte e retro hanno lunghezze diverse ({{frontCount}} fronti contro {{backCount}} retri). Le {{extraCount}} pagina/e in eccesso del blocco più lungo verranno aggiunte spaiate alla fine.",
+ "missingFileTitle": "File mancante",
+ "missingFileMessage": "Carica un PDF di almeno 2 pagine prima di procedere.",
+ "invalidSplitTitle": "Punto di divisione non valido",
+ "invalidSplitMessage": "Il punto di divisione deve essere compreso tra la pagina 1 e la pagina {{maxSplit}}.",
+ "unevenConfirmTitle": "Blocchi disuguali",
+ "unevenConfirmMessage": "Il blocco dei fronti ha {{frontCount}} pagina/e e quello dei retri {{backCount}} pagina/e.\nDi solito significa che una pagina è stata scansionata due volte o che ne manca una.\n\nLe {{extraCount}} pagina/e spaiate verranno aggiunte alla fine.\n\nContinuare comunque?",
+ "invalidGroupTitle": "Dimensione del gruppo non valida",
+ "invalidGroupMessage": "Inserisci un numero valido di pagine per documento originale.",
+ "collatingLoader": "Fascicolazione della scansione fronte/retro...",
+ "successMessage": "PDF fascicolato generato correttamente.",
+ "successGroupedMessage": "Fascicolazione completata. Generati {{fileCount}} file raggruppati.",
+ "processErrorMessage": "Impossibile fascicolare il PDF.",
+ "howItWorksStep1Title": "Carica la scansione",
+ "howItWorksStep1": "Trascina il PDF unico che contiene prima tutti i fronti e poi tutti i retri.",
+ "howItWorksStep2Title": "Imposta il punto di divisione",
+ "howItWorksStep2": "Indica dove finisce il blocco dei fronti, poi scegli se il blocco dei retri deve essere invertito.",
+ "howItWorksStep3Title": "Fascicola e scarica",
+ "howItWorksStep3": "Ottieni un PDF con le pagine alternate, oppure un file ZIP con un file separato per ogni documento originale.",
+ "faqOddPagesQuestion": "E se la mia scansione ha un numero dispari di pagine?",
+ "faqOddPagesAnswer": "Una scansione fronte/retro dovrebbe avere un numero pari di pagine. In caso contrario, probabilmente una pagina è stata scansionata due volte o saltata. Lo strumento ti avvisa prima di procedere e le pagine spaiate vengono aggiunte alla fine, così non si perde nulla.",
+ "faqBackOrderQuestion": "Perché dovrei mantenere il blocco dei retri così com'è?",
+ "faqBackOrderAnswer": "Dipende da come è stata inserita la pila nel secondo passaggio. Girando l'intera pila i retri risultano in ordine inverso, che è l'impostazione predefinita. Se il tuo scanner li ha restituiti nello stesso ordine dei fronti, scegli di mantenere il blocco così com'è."
+ },
"addAttachments": {
"name": "Aggiungi Allegati",
"subtitle": "Incorpora uno o più file nel tuo PDF."
diff --git a/public/locales/ja/common.json b/public/locales/ja/common.json
index ed3583ba..8b98d7cd 100644
--- a/public/locales/ja/common.json
+++ b/public/locales/ja/common.json
@@ -216,7 +216,7 @@
},
"analytics": {
"question": "私を追跡するためにCookieまたは分析を使用しますか?",
- "answer": "私たちはあなたのプライバシーを大切にしています。BentoPDFは個人情報を追跡しません。私たちは匿名の訪問数を確認するためにのみSimple Analyticsを使用します。これは私たちのサイトにアクセスしたユーザーの数を知ることはできますが、あなたが誰であるかは決して分からないことを意味します。 Simple AnalyticsはGDPRに完全に準拠しており、プライバシーを尊重します。"
+ "answer": "いいえ。BentoPDF はあなたを一切追跡しません。Cookie、アナリティクス、トラッキングスクリプトは使用しておらず、公開サイト bentopdf.com でも同様です。あなたが誰で、ツールで何をしているかを私たちが知ることはありません。"
}
},
"testimonials": {
diff --git a/public/locales/ja/tools.json b/public/locales/ja/tools.json
index 4d42e742..001006af 100644
--- a/public/locales/ja/tools.json
+++ b/public/locales/ja/tools.json
@@ -55,6 +55,122 @@
"name": "PDFエディター",
"subtitle": "PDFへの注釈追加、ハイライト、黒塗り、コメント、図形や画像の挿入、検索、および表示が可能です。"
},
+ "editPdfText": {
+ "name": "PDFテキストの編集",
+ "subtitle": "段落をクリックしてその場で編集。テキストは自動で再配置され、フォントやスタイルも変更できます。",
+ "saved": "PDF を保存しました!({{size}} KB)",
+ "failedToLoad": "PDF テキストエディタを読み込めませんでした。",
+ "howToUpload": "PDF をアップロード",
+ "howToClick": "段落をクリック",
+ "howToRestyle": "自由にスタイル変更",
+ "relatedPdfEditor": "PDF に注釈やマークを追加",
+ "relatedSignPdf": "署名を描く・入力・アップロード",
+ "relatedWatermark": "テキストや画像を重ねる",
+ "relatedStamps": "ページにスタンプを配置",
+ "relatedCompress": "ファイルサイズを縮小",
+ "relatedOrganizeName": "PDF を整理",
+ "relatedOrganize": "ページの並べ替えと管理",
+ "open": "開く",
+ "export": "書き出し",
+ "filterAll": "すべて",
+ "filterText": "テキスト",
+ "filterImages": "画像",
+ "filterShapes": "図形",
+ "addText": "テキストを追加",
+ "langAuto": "自動",
+ "change": "変更",
+ "ignore": "無視",
+ "next": "次へ",
+ "replace": "置換",
+ "replaceAll": "すべて置換",
+ "emptyState": "PDF を開いて、テキスト・画像・図形を編集します。",
+ "choosePdf": "PDF を選択…",
+ "font": "フォント",
+ "systemFonts": "システムフォント",
+ "color": "色",
+ "alignment": "配置",
+ "style": "スタイル",
+ "lists": "リスト",
+ "outline": "アウトライン",
+ "spacing": "間隔",
+ "direction": "方向",
+ "dirAuto": "自動",
+ "pathStroke": "パスの線",
+ "tipBackToUpload": "アップロードに戻る",
+ "tipOpenAnotherPdf": "別の PDF を開く",
+ "tipExportS": "書き出し (⌘S)",
+ "tipUndoZ": "取り消す (⌘Z)",
+ "tipRedoZ": "やり直す (⇧⌘Z)",
+ "tipFileDetails": "ファイル情報",
+ "tipEditExistingContent": "既存のコンテンツを編集",
+ "tipWhatTheEditToolTouchesNarrowTheScopeOnBus": "編集ツールの対象範囲 — 要素の多いページでは範囲を絞り込みます",
+ "tipClickThePageToAddATextBox": "ページをクリックしてテキストボックスを追加",
+ "tipSpellCheckLanguage": "スペルチェックの言語",
+ "tipFindAndReplace": "検索と置換",
+ "tipAddAnImage": "画像を追加",
+ "tipCopyThisPageSTextInReadingOrder": "このページのテキストを読み順でコピー",
+ "tipCheckTheSpellingOfThisPage": "このページのスペルをチェック",
+ "tipShowOrHideTheFormatPanel": "書式パネルの表示/非表示",
+ "phReplaceWith": "置換後の文字列",
+ "tipAddToYourDictionary": "辞書に追加",
+ "tipClose": "閉じる",
+ "phFind": "検索",
+ "tipPreviousMatch": "前の一致",
+ "tipNextMatch": "次の一致",
+ "tipRotateLeft90": "左に 90° 回転",
+ "tipRotateRight90": "右に 90° 回転",
+ "tipFlipHorizontal": "左右反転",
+ "tipFlipVertical": "上下反転",
+ "tipReplaceThisImageKeepingItsPlacement": "配置を保ったままこの画像を置き換え",
+ "tipAlternateTextForAccessibilityTaggedPdfs": "アクセシビリティ用の代替テキスト(タグ付き PDF)",
+ "tipBringToFront": "最前面へ",
+ "tipSendToBack": "最背面へ",
+ "tipDuplicate": "複製",
+ "tipDelete": "削除",
+ "tipAlignLeftEdges": "左端を揃える",
+ "tipAlignHorizontalCenters": "左右中央に揃える",
+ "tipAlignRightEdges": "右端を揃える",
+ "tipAlignTops": "上端を揃える",
+ "tipAlignVerticalCenters": "上下中央に揃える",
+ "tipAlignBottoms": "下端を揃える",
+ "tipDistributeHorizontally": "左右に整列",
+ "tipDistributeVertically": "上下に整列",
+ "tipFontSize": "フォントサイズ",
+ "tipListFontsInstalledOnThisDeviceChromium": "このデバイスにインストールされたフォントを一覧表示(Chromium)",
+ "tipCustomTextColor": "カスタム文字色",
+ "tipAlignLeft": "左揃え",
+ "tipAlignCenter": "中央揃え",
+ "tipAlignRight": "右揃え",
+ "tipJustify": "両端揃え",
+ "tipBold": "太字",
+ "tipItalic": "斜体",
+ "tipUnderline": "下線",
+ "tipStrikethrough": "取り消し線",
+ "tipSuperscript": "上付き文字",
+ "tipSubscript": "下付き文字",
+ "tipToggleBulletedItem": "箇条書き項目の切り替え",
+ "tipToggleNumberedItem": "番号付き項目の切り替え",
+ "tipNumberedListMarkerStyle": "番号付きリストの記号スタイル",
+ "tipDecreaseListIndentTab": "インデントを減らす (⇧Tab)",
+ "tipIncreaseListIndentTab": "インデントを増やす (Tab)",
+ "tipTextOutlineColor": "文字の輪郭の色",
+ "tipOutlineWidth0None": "輪郭の太さ(0 = なし)",
+ "tipFromContent": "コンテンツから",
+ "tipLeftToRight": "左から右",
+ "tipRightToLeft": "右から左",
+ "tipStrokeColor": "線の色",
+ "tipStrokeWidth": "線の太さ",
+ "tipZoomPresets": "ズームのプリセット",
+ "tipZoomOut": "縮小",
+ "tipZoomIn": "拡大",
+ "tipFitPage": "ページに合わせる",
+ "tipPreviousPage": "前のページ",
+ "tipTypeAPageNumberAndPressEnter": "ページ番号を入力して Enter を押してください",
+ "tipNextPage": "次のページ",
+ "tipDocumentProperties": "ドキュメントのプロパティ",
+ "tipHideBar": "バーを隠す",
+ "tipShowBar": "バーを表示"
+ },
"jpgToPdf": {
"name": "JPGからPDF",
"subtitle": "JPG、JPEG、およびJPEG2000(JP2/JPX)形式の画像からPDFを作成します。"
@@ -514,6 +630,51 @@
"retainPageLabels": "各ファイルの元のページラベルを保持する",
"retainPageLabelsHint": "オフ(デフォルト): マージされたPDFは1–Nの自然なページ番号を使用します。オン: 各ファイルの元のページラベルが保持されます(ラベルが重複する可能性があります)。"
},
+ "duplexCollate": {
+ "name": "両面スキャンの並べ替え",
+ "subtitle": "2つのブロック(先に表面すべて、次に裏面すべて)としてスキャンしたPDFを、正しい表面/裏面の順に並べ替えます。",
+ "totalPagesLabel": "総ページ数:",
+ "splitAfterPageLabel": "分割位置のページ",
+ "splitAfterPagePlaceholder": "例: 50",
+ "backBlockOrderLabel": "裏面ブロックの順序",
+ "backBlockReverseOption": "裏面ブロックを逆順にする(束を裏返した場合に一般的)",
+ "backBlockKeepOption": "裏面ブロックをそのままにする",
+ "autoSplitButton": "分割位置を自動判定(半分)",
+ "exportGroupedLabel": "元の文書のページ数ごとにファイルを分けて書き出す",
+ "pagesPerDocumentLabel": "元の文書1件あたりのページ数",
+ "groupedHelp": "有効にすると、分割された文書ごとに1つのPDFを含むZIPファイルが出力されます。",
+ "collateButton": "PDFを並べ替え",
+ "invalidFileTitle": "無効なファイル",
+ "invalidFileMessage": "PDFファイルを選択してください。",
+ "loadingPdf": "PDFを読み込んでいます...",
+ "loadErrorMessage": "PDFファイルを読み込めませんでした。",
+ "fileMeta": "{{size}} - {{count}} ページ",
+ "oddPageWarning": "ページ数が奇数です({{count}} ページ)。両面スキャンのページ数は偶数になるはずです。ページの不足または重複の可能性があります。",
+ "previewSummary": "表面ブロック: {{frontCount}} ページ、裏面ブロック: {{backCount}} ページ。出力: {{outputCount}} ページ(表面/裏面の順)。",
+ "previewMismatchWarning": "表面と裏面のブロックのページ数が異なります(表面 {{frontCount}} ページ、裏面 {{backCount}} ページ)。多い側の余分な {{extraCount}} ページは、対にならないまま末尾に追加されます。",
+ "missingFileTitle": "ファイルがありません",
+ "missingFileMessage": "処理する前に、2ページ以上のPDFをアップロードしてください。",
+ "invalidSplitTitle": "無効な分割位置",
+ "invalidSplitMessage": "分割位置は1ページ目から{{maxSplit}}ページ目の間で指定してください。",
+ "unevenConfirmTitle": "ブロックのページ数が不一致",
+ "unevenConfirmMessage": "表面ブロックは {{frontCount}} ページ、裏面ブロックは {{backCount}} ページです。\n通常、これはページが二重にスキャンされたか、不足していることを意味します。\n\n対にならない {{extraCount}} ページは末尾に追加されます。\n\nこのまま続行しますか?",
+ "invalidGroupTitle": "無効なグループサイズ",
+ "invalidGroupMessage": "元の文書1件あたりの有効なページ数を入力してください。",
+ "collatingLoader": "両面スキャンを並べ替えています...",
+ "successMessage": "並べ替えたPDFを作成しました。",
+ "successGroupedMessage": "並べ替えが完了しました。{{fileCount}} 個のファイルを作成しました。",
+ "processErrorMessage": "PDFを並べ替えられませんでした。",
+ "howItWorksStep1Title": "スキャンをアップロード",
+ "howItWorksStep1": "表面すべての後に裏面すべてが並んだ1つのPDFをドロップします。",
+ "howItWorksStep2Title": "分割位置を設定",
+ "howItWorksStep2": "表面ブロックが終わる位置を指定し、裏面ブロックを逆順にする必要があるかを選びます。",
+ "howItWorksStep3Title": "並べ替えてダウンロード",
+ "howItWorksStep3": "ページを交互に並べた1つのPDF、または元の文書ごとにファイルを分けたZIPを取得できます。",
+ "faqOddPagesQuestion": "スキャンのページ数が奇数の場合はどうなりますか?",
+ "faqOddPagesAnswer": "両面スキャンのページ数は偶数になるはずです。そうでない場合は、ページが二重にスキャンされたか、読み取られなかった可能性があります。処理前に警告が表示され、対にならないページは末尾に追加されるため、失われるものはありません。",
+ "faqBackOrderQuestion": "裏面ブロックをそのままにするのはどのような場合ですか?",
+ "faqBackOrderAnswer": "2回目の読み取りで束をどのようにセットしたかによります。束全体を裏返すと裏面は逆順になり、これが既定の設定です。スキャナーが表面と同じ順序で出力した場合は、ブロックをそのままにする方を選んでください。"
+ },
"addAttachments": {
"name": "添付ファイルの追加",
"subtitle": "1つ以上のファイルをPDFに埋め込みます。"
diff --git a/public/locales/ko/common.json b/public/locales/ko/common.json
index a0652a0c..e5cffc05 100644
--- a/public/locales/ko/common.json
+++ b/public/locales/ko/common.json
@@ -216,7 +216,7 @@
},
"analytics": {
"question": "쿠키나 분석 도구로 사용자를 추적하나요?",
- "answer": "여러분의 프라이버시를 소중히 여깁니다. BentoPDF는 개인정보를 추적하지 않으며, 익명 방문 수 확인만을 위해 Simple Analytics를 사용합니다. 방문자 수는 알 수 있지만 누가 방문했는지는 절대 알 수 없습니다. Simple Analytics는 GDPR을 완전히 준수합니다."
+ "answer": "아니요. BentoPDF는 사용자를 전혀 추적하지 않습니다. 쿠키, 분석 도구, 추적 스크립트를 사용하지 않으며, 실제 웹사이트 bentopdf.com에서도 마찬가지입니다. 저희는 사용자가 누구인지, 도구로 무엇을 하는지 결코 알 수 없습니다."
}
},
"testimonials": {
diff --git a/public/locales/ko/tools.json b/public/locales/ko/tools.json
index 165d11f4..fa78425f 100644
--- a/public/locales/ko/tools.json
+++ b/public/locales/ko/tools.json
@@ -55,6 +55,122 @@
"name": "PDF 편집기",
"subtitle": "주석, 하이라이트, 마스킹, 댓글, 도형/이미지 추가, 검색 및 보기."
},
+ "editPdfText": {
+ "name": "PDF 텍스트 편집",
+ "subtitle": "아무 단락이나 클릭해 실시간 리플로우, 글꼴, 스타일과 함께 바로 편집하세요.",
+ "saved": "PDF를 저장했습니다! ({{size}} KB)",
+ "failedToLoad": "PDF 텍스트 편집기를 불러오지 못했습니다.",
+ "howToUpload": "PDF 업로드",
+ "howToClick": "문단 클릭",
+ "howToRestyle": "무엇이든 스타일 변경",
+ "relatedPdfEditor": "PDF에 주석 달고 표시하기",
+ "relatedSignPdf": "서명 그리기, 입력 또는 업로드",
+ "relatedWatermark": "텍스트나 이미지 찍기",
+ "relatedStamps": "페이지에 스탬프 배치",
+ "relatedCompress": "파일 크기 줄이기",
+ "relatedOrganizeName": "PDF 정리",
+ "relatedOrganize": "페이지 순서 변경 및 관리",
+ "open": "열기",
+ "export": "내보내기",
+ "filterAll": "전체",
+ "filterText": "텍스트",
+ "filterImages": "이미지",
+ "filterShapes": "도형",
+ "addText": "텍스트 추가",
+ "langAuto": "자동",
+ "change": "변경",
+ "ignore": "무시",
+ "next": "다음",
+ "replace": "바꾸기",
+ "replaceAll": "모두 바꾸기",
+ "emptyState": "PDF를 열어 텍스트, 이미지, 도형을 편집하세요.",
+ "choosePdf": "PDF 선택…",
+ "font": "글꼴",
+ "systemFonts": "시스템 글꼴",
+ "color": "색상",
+ "alignment": "정렬",
+ "style": "스타일",
+ "lists": "목록",
+ "outline": "윤곽선",
+ "spacing": "간격",
+ "direction": "방향",
+ "dirAuto": "자동",
+ "pathStroke": "패스 선",
+ "tipBackToUpload": "업로드로 돌아가기",
+ "tipOpenAnotherPdf": "다른 PDF 열기",
+ "tipExportS": "내보내기 (⌘S)",
+ "tipUndoZ": "실행 취소 (⌘Z)",
+ "tipRedoZ": "다시 실행 (⇧⌘Z)",
+ "tipFileDetails": "파일 정보",
+ "tipEditExistingContent": "기존 콘텐츠 편집",
+ "tipWhatTheEditToolTouchesNarrowTheScopeOnBus": "편집 도구가 적용되는 대상 — 복잡한 페이지에서는 범위를 좁히세요",
+ "tipClickThePageToAddATextBox": "페이지를 클릭해 텍스트 상자를 추가하세요",
+ "tipSpellCheckLanguage": "맞춤법 검사 언어",
+ "tipFindAndReplace": "찾기 및 바꾸기",
+ "tipAddAnImage": "이미지 추가",
+ "tipCopyThisPageSTextInReadingOrder": "이 페이지의 텍스트를 읽기 순서대로 복사",
+ "tipCheckTheSpellingOfThisPage": "이 페이지의 맞춤법 검사",
+ "tipShowOrHideTheFormatPanel": "서식 패널 표시/숨기기",
+ "phReplaceWith": "바꿀 내용",
+ "tipAddToYourDictionary": "사전에 추가",
+ "tipClose": "닫기",
+ "phFind": "찾기",
+ "tipPreviousMatch": "이전 일치 항목",
+ "tipNextMatch": "다음 일치 항목",
+ "tipRotateLeft90": "왼쪽으로 90° 회전",
+ "tipRotateRight90": "오른쪽으로 90° 회전",
+ "tipFlipHorizontal": "좌우 반전",
+ "tipFlipVertical": "상하 반전",
+ "tipReplaceThisImageKeepingItsPlacement": "위치를 유지한 채 이 이미지 바꾸기",
+ "tipAlternateTextForAccessibilityTaggedPdfs": "접근성을 위한 대체 텍스트(태그된 PDF)",
+ "tipBringToFront": "맨 앞으로 가져오기",
+ "tipSendToBack": "맨 뒤로 보내기",
+ "tipDuplicate": "복제",
+ "tipDelete": "삭제",
+ "tipAlignLeftEdges": "왼쪽 가장자리 정렬",
+ "tipAlignHorizontalCenters": "가로 가운데 정렬",
+ "tipAlignRightEdges": "오른쪽 가장자리 정렬",
+ "tipAlignTops": "위쪽 가장자리 정렬",
+ "tipAlignVerticalCenters": "세로 가운데 정렬",
+ "tipAlignBottoms": "아래쪽 가장자리 정렬",
+ "tipDistributeHorizontally": "가로로 균등 배분",
+ "tipDistributeVertically": "세로로 균등 배분",
+ "tipFontSize": "글꼴 크기",
+ "tipListFontsInstalledOnThisDeviceChromium": "이 기기에 설치된 글꼴 목록 보기(Chromium)",
+ "tipCustomTextColor": "사용자 지정 텍스트 색",
+ "tipAlignLeft": "왼쪽 정렬",
+ "tipAlignCenter": "가운데 정렬",
+ "tipAlignRight": "오른쪽 정렬",
+ "tipJustify": "양쪽 정렬",
+ "tipBold": "굵게",
+ "tipItalic": "기울임꼴",
+ "tipUnderline": "밑줄",
+ "tipStrikethrough": "취소선",
+ "tipSuperscript": "위 첨자",
+ "tipSubscript": "아래 첨자",
+ "tipToggleBulletedItem": "글머리 기호 켜기/끄기",
+ "tipToggleNumberedItem": "번호 매기기 켜기/끄기",
+ "tipNumberedListMarkerStyle": "번호 매기기 기호 스타일",
+ "tipDecreaseListIndentTab": "들여쓰기 줄이기 (⇧Tab)",
+ "tipIncreaseListIndentTab": "들여쓰기 늘리기 (Tab)",
+ "tipTextOutlineColor": "텍스트 윤곽선 색",
+ "tipOutlineWidth0None": "윤곽선 두께(0 = 없음)",
+ "tipFromContent": "콘텐츠에서",
+ "tipLeftToRight": "왼쪽에서 오른쪽",
+ "tipRightToLeft": "오른쪽에서 왼쪽",
+ "tipStrokeColor": "선 색",
+ "tipStrokeWidth": "선 두께",
+ "tipZoomPresets": "확대/축소 사전 설정",
+ "tipZoomOut": "축소",
+ "tipZoomIn": "확대",
+ "tipFitPage": "페이지 맞춤",
+ "tipPreviousPage": "이전 페이지",
+ "tipTypeAPageNumberAndPressEnter": "페이지 번호를 입력하고 Enter를 누르세요",
+ "tipNextPage": "다음 페이지",
+ "tipDocumentProperties": "문서 속성",
+ "tipHideBar": "막대 숨기기",
+ "tipShowBar": "막대 표시"
+ },
"jpgToPdf": {
"name": "JPG를 PDF로",
"subtitle": "JPG, JPEG, JPEG2000(JP2/JPX) 이미지로 PDF를 만듭니다."
@@ -514,6 +630,51 @@
"retainPageLabels": "각 파일의 원래 페이지 레이블 유지",
"retainPageLabelsHint": "꺼짐(기본): 병합된 PDF는 1–N의 자연스러운 페이지 번호를 사용합니다. 켜짐: 각 파일의 원래 페이지 레이블이 유지됩니다(중복 레이블이 발생할 수 있음)."
},
+ "duplexCollate": {
+ "name": "양면 스캔 정렬",
+ "subtitle": "두 묶음(앞면 전체, 그다음 뒷면 전체)으로 스캔한 PDF를 올바른 앞/뒤 순서로 재정렬합니다.",
+ "totalPagesLabel": "전체 페이지:",
+ "splitAfterPageLabel": "분할 기준 페이지",
+ "splitAfterPagePlaceholder": "예: 50",
+ "backBlockOrderLabel": "뒷면 묶음 순서",
+ "backBlockReverseOption": "뒷면 묶음 뒤집기 (용지를 뒤집어 스캔한 경우 일반적)",
+ "backBlockKeepOption": "뒷면 묶음 그대로 두기",
+ "autoSplitButton": "분할 지점 자동 감지 (절반)",
+ "exportGroupedLabel": "원본 문서 길이에 따라 파일을 나누어 내보내기",
+ "pagesPerDocumentLabel": "원본 문서당 페이지 수",
+ "groupedHelp": "사용하면 나뉜 문서마다 PDF 하나씩 담긴 ZIP 파일로 저장됩니다.",
+ "collateButton": "PDF 정렬",
+ "invalidFileTitle": "잘못된 파일",
+ "invalidFileMessage": "PDF 파일을 선택하세요.",
+ "loadingPdf": "PDF 불러오는 중...",
+ "loadErrorMessage": "PDF 파일을 불러오지 못했습니다.",
+ "fileMeta": "{{size}} - {{count}}페이지",
+ "oddPageWarning": "페이지 수가 홀수입니다({{count}}페이지). 양면 스캔은 페이지 수가 짝수여야 합니다. 페이지가 누락되었거나 중복되었을 수 있습니다.",
+ "previewSummary": "앞면 묶음: {{frontCount}}페이지, 뒷면 묶음: {{backCount}}페이지. 결과: 앞/뒤 순서로 {{outputCount}}페이지.",
+ "previewMismatchWarning": "앞면과 뒷면 묶음의 길이가 다릅니다(앞면 {{frontCount}}페이지, 뒷면 {{backCount}}페이지). 더 긴 쪽의 남은 {{extraCount}}페이지는 짝 없이 맨 뒤에 추가됩니다.",
+ "missingFileTitle": "파일 없음",
+ "missingFileMessage": "처리하기 전에 2페이지 이상인 PDF를 업로드하세요.",
+ "invalidSplitTitle": "잘못된 분할 지점",
+ "invalidSplitMessage": "분할 지점은 1페이지에서 {{maxSplit}}페이지 사이여야 합니다.",
+ "unevenConfirmTitle": "묶음 길이 불일치",
+ "unevenConfirmMessage": "앞면 묶음은 {{frontCount}}페이지, 뒷면 묶음은 {{backCount}}페이지입니다.\n보통 페이지가 두 번 스캔되었거나 누락되었다는 뜻입니다.\n\n짝이 없는 {{extraCount}}페이지는 맨 뒤에 추가됩니다.\n\n계속하시겠습니까?",
+ "invalidGroupTitle": "잘못된 그룹 크기",
+ "invalidGroupMessage": "원본 문서당 페이지 수를 올바르게 입력하세요.",
+ "collatingLoader": "양면 스캔 정렬 중...",
+ "successMessage": "정렬된 PDF를 만들었습니다.",
+ "successGroupedMessage": "정렬이 완료되었습니다. {{fileCount}}개 파일을 만들었습니다.",
+ "processErrorMessage": "PDF를 정렬하지 못했습니다.",
+ "howItWorksStep1Title": "스캔 업로드",
+ "howItWorksStep1": "앞면 전체 다음에 뒷면 전체가 들어 있는 PDF 한 개를 놓으세요.",
+ "howItWorksStep2Title": "분할 지점 설정",
+ "howItWorksStep2": "앞면 묶음이 끝나는 위치를 지정한 뒤, 뒷면 묶음을 뒤집어야 하는지 선택하세요.",
+ "howItWorksStep3Title": "정렬 후 다운로드",
+ "howItWorksStep3": "페이지가 번갈아 배치된 PDF 하나를 받거나, 원본 문서마다 파일이 나뉜 ZIP을 받으세요.",
+ "faqOddPagesQuestion": "스캔한 페이지 수가 홀수이면 어떻게 되나요?",
+ "faqOddPagesAnswer": "양면 스캔은 페이지 수가 짝수여야 합니다. 그렇지 않다면 한 페이지가 두 번 스캔되었거나 빠졌을 가능성이 큽니다. 처리 전에 경고가 표시되며, 짝이 없는 페이지는 맨 뒤에 추가되므로 사라지는 내용은 없습니다.",
+ "faqBackOrderQuestion": "뒷면 묶음을 그대로 두는 경우는 언제인가요?",
+ "faqBackOrderAnswer": "두 번째 스캔에서 용지를 어떻게 넣었는지에 따라 다릅니다. 전체 묶음을 뒤집으면 뒷면이 역순이 되며, 이것이 기본 설정입니다. 스캐너가 앞면과 같은 순서로 출력했다면 묶음을 그대로 두도록 선택하세요."
+ },
"addAttachments": {
"name": "첨부파일 추가",
"subtitle": "PDF에 파일을 첨부합니다."
diff --git a/public/locales/nl/common.json b/public/locales/nl/common.json
index 0cf2153c..a5a44718 100644
--- a/public/locales/nl/common.json
+++ b/public/locales/nl/common.json
@@ -216,7 +216,7 @@
},
"analytics": {
"question": "Gebruikt BentoPDF cookies of analytics om mij te volgen?",
- "answer": "We geven om je privacy. BentoPDF houdt geen persoonlijke gegevens bij. We gebruiken Simple Analytics alleen om anonieme bezoekersaantallen te zien. Dit betekent dat we kunnen weten hoeveel mensen onze site bezoeken, maar we weten nooit wie jij bent. Simple Analytics is volledig AVG-conform en respecteert je privacy."
+ "answer": "Nee. BentoPDF volgt je helemaal niet. We gebruiken geen cookies, analytics of trackingscripts – ook niet op de live website bentopdf.com. We weten nooit wie je bent of wat je met de tools doet."
}
},
"testimonials": {
diff --git a/public/locales/nl/tools.json b/public/locales/nl/tools.json
index 8020191e..766e4312 100644
--- a/public/locales/nl/tools.json
+++ b/public/locales/nl/tools.json
@@ -55,6 +55,122 @@
"name": "PDF Bewerken",
"subtitle": "PDF's annoteren, markeren, redigeren, commentaar toevoegen, vormen/afbeelding toevoegen, doorzoeken and weergeven."
},
+ "editPdfText": {
+ "name": "PDF-tekst Bewerken",
+ "subtitle": "Klik op een alinea en bewerk deze direct, met live tekstomloop, lettertypen en opmaak.",
+ "saved": "PDF is opgeslagen! ({{size}} KB)",
+ "failedToLoad": "Kan de PDF-teksteditor niet laden.",
+ "howToUpload": "PDF uploaden",
+ "howToClick": "Klik op een alinea",
+ "howToRestyle": "Alles opnieuw vormgeven",
+ "relatedPdfEditor": "PDF's annoteren en markeren",
+ "relatedSignPdf": "Handtekening tekenen, typen of uploaden",
+ "relatedWatermark": "Tekst of afbeeldingen stempelen",
+ "relatedStamps": "Stempels op pagina's plaatsen",
+ "relatedCompress": "Bestandsgrootte verkleinen",
+ "relatedOrganizeName": "PDF ordenen",
+ "relatedOrganize": "Pagina's herschikken en beheren",
+ "open": "Openen",
+ "export": "Exporteren",
+ "filterAll": "Alles",
+ "filterText": "Tekst",
+ "filterImages": "Afbeeldingen",
+ "filterShapes": "Vormen",
+ "addText": "Tekst toevoegen",
+ "langAuto": "Automatisch",
+ "change": "Wijzigen",
+ "ignore": "Negeren",
+ "next": "Volgende",
+ "replace": "Vervangen",
+ "replaceAll": "Alles vervangen",
+ "emptyState": "Open een PDF om de tekst, afbeeldingen en vormen te bewerken.",
+ "choosePdf": "Kies een PDF…",
+ "font": "Lettertype",
+ "systemFonts": "Systeemlettertypen",
+ "color": "Kleur",
+ "alignment": "Uitlijning",
+ "style": "Stijl",
+ "lists": "Lijsten",
+ "outline": "Omtrek",
+ "spacing": "Afstand",
+ "direction": "Richting",
+ "dirAuto": "Automatisch",
+ "pathStroke": "Padomtrek",
+ "tipBackToUpload": "Terug naar uploaden",
+ "tipOpenAnotherPdf": "Nog een PDF openen",
+ "tipExportS": "Exporteren (⌘S)",
+ "tipUndoZ": "Ongedaan maken (⌘Z)",
+ "tipRedoZ": "Opnieuw (⇧⌘Z)",
+ "tipFileDetails": "Bestandsgegevens",
+ "tipEditExistingContent": "Bestaande inhoud bewerken",
+ "tipWhatTheEditToolTouchesNarrowTheScopeOnBus": "Wat het hulpmiddel Bewerken raakt — beperk het bereik op drukke pagina's",
+ "tipClickThePageToAddATextBox": "Klik op de pagina om een tekstvak toe te voegen",
+ "tipSpellCheckLanguage": "Taal van de spellingcontrole",
+ "tipFindAndReplace": "Zoeken en vervangen",
+ "tipAddAnImage": "Een afbeelding toevoegen",
+ "tipCopyThisPageSTextInReadingOrder": "Tekst van deze pagina in leesvolgorde kopiëren",
+ "tipCheckTheSpellingOfThisPage": "Spelling van deze pagina controleren",
+ "tipShowOrHideTheFormatPanel": "Opmaakpaneel tonen of verbergen",
+ "phReplaceWith": "Vervangen door",
+ "tipAddToYourDictionary": "Aan woordenboek toevoegen",
+ "tipClose": "Sluiten",
+ "phFind": "Zoeken",
+ "tipPreviousMatch": "Vorige overeenkomst",
+ "tipNextMatch": "Volgende overeenkomst",
+ "tipRotateLeft90": "90° naar links draaien",
+ "tipRotateRight90": "90° naar rechts draaien",
+ "tipFlipHorizontal": "Horizontaal spiegelen",
+ "tipFlipVertical": "Verticaal spiegelen",
+ "tipReplaceThisImageKeepingItsPlacement": "Deze afbeelding vervangen met behoud van plaatsing",
+ "tipAlternateTextForAccessibilityTaggedPdfs": "Alternatieve tekst voor toegankelijkheid (getagde PDF's)",
+ "tipBringToFront": "Naar voorgrond",
+ "tipSendToBack": "Naar achtergrond",
+ "tipDuplicate": "Dupliceren",
+ "tipDelete": "Verwijderen",
+ "tipAlignLeftEdges": "Linkerranden uitlijnen",
+ "tipAlignHorizontalCenters": "Horizontaal centreren",
+ "tipAlignRightEdges": "Rechterranden uitlijnen",
+ "tipAlignTops": "Bovenranden uitlijnen",
+ "tipAlignVerticalCenters": "Verticaal centreren",
+ "tipAlignBottoms": "Onderranden uitlijnen",
+ "tipDistributeHorizontally": "Horizontaal verdelen",
+ "tipDistributeVertically": "Verticaal verdelen",
+ "tipFontSize": "Tekengrootte",
+ "tipListFontsInstalledOnThisDeviceChromium": "Lettertypen op dit apparaat weergeven (Chromium)",
+ "tipCustomTextColor": "Aangepaste tekstkleur",
+ "tipAlignLeft": "Links uitlijnen",
+ "tipAlignCenter": "Centreren",
+ "tipAlignRight": "Rechts uitlijnen",
+ "tipJustify": "Uitvullen",
+ "tipBold": "Vet",
+ "tipItalic": "Cursief",
+ "tipUnderline": "Onderstrepen",
+ "tipStrikethrough": "Doorhalen",
+ "tipSuperscript": "Superscript",
+ "tipSubscript": "Subscript",
+ "tipToggleBulletedItem": "Opsommingsteken in-/uitschakelen",
+ "tipToggleNumberedItem": "Genummerd item in-/uitschakelen",
+ "tipNumberedListMarkerStyle": "Markeringsstijl voor genummerde lijsten",
+ "tipDecreaseListIndentTab": "Inspringing verkleinen (⇧Tab)",
+ "tipIncreaseListIndentTab": "Inspringing vergroten (Tab)",
+ "tipTextOutlineColor": "Kleur tekstomtrek",
+ "tipOutlineWidth0None": "Omtrekbreedte (0 = geen)",
+ "tipFromContent": "Uit inhoud",
+ "tipLeftToRight": "Van links naar rechts",
+ "tipRightToLeft": "Van rechts naar links",
+ "tipStrokeColor": "Lijnkleur",
+ "tipStrokeWidth": "Lijndikte",
+ "tipZoomPresets": "Zoomvoorinstellingen",
+ "tipZoomOut": "Uitzoomen",
+ "tipZoomIn": "Inzoomen",
+ "tipFitPage": "Pagina passend maken",
+ "tipPreviousPage": "Vorige pagina",
+ "tipTypeAPageNumberAndPressEnter": "Typ een paginanummer en druk op Enter",
+ "tipNextPage": "Volgende pagina",
+ "tipDocumentProperties": "Documenteigenschappen",
+ "tipHideBar": "Balk verbergen",
+ "tipShowBar": "Balk tonen"
+ },
"jpgToPdf": {
"name": "JPG naar PDF",
"subtitle": "Een of meer JPG-afbeeldingen opslaan als PDF."
@@ -103,7 +219,7 @@
"statusReadingFileMainThread": "Bestand lezen (Hoofdthread)...",
"statusGenerating": "Inhoudsopgave genereren...",
"errorReadingFileWithMessage": "Fout bij lezen bestand: {{message}}",
- "successGeneratedDownloadStarted": "Inhoudsopgave succesvol gegenereerd! Download gestart.",
+ "successGeneratedDownloadStarted": "Inhoudsopgave met succes gegenereerd! Download gestart.",
"workerErrorWithMessage": "Fout: {{message}}",
"workerErrorOccurred": "Er is een worker-fout opgetreden. Controleer de console voor details."
},
@@ -159,7 +275,7 @@
"invalidRangeMessage": "Regel {{number}} heeft een ongeldig paginabereik: {{range}}",
"allPages": "alle pagina's",
"emptyOutputMessage": "CoherentPDF heeft een leeg bestand geproduceerd.",
- "successMessage": "Paginalabels zijn succesvol toegevoegd!",
+ "successMessage": "Paginalabels zijn met succes toegevoegd!",
"processErrorMessage": "Kon de paginalabels niet toevoegen.",
"styleOptions": {
"DecimalArabic": "Decimale cijfers",
@@ -310,7 +426,7 @@
"converting": "JSON-bestanden naar PDF converteren...",
"readError": "Fout bij lezen bestanden: {{message}}",
"creatingZip": "ZIP-bestand maken...",
- "success": "JSON-bestanden succesvol naar PDF geconverteerd! ZIP-download gestart.",
+ "success": "JSON-bestanden met succes naar PDF geconverteerd! ZIP-download gestart.",
"zipError": "Fout bij maken ZIP: {{message}}",
"workerError": "Worker-fout: {{message}}",
"getStarted": "Selecteer JSON-bestanden om te beginnen"
@@ -428,7 +544,7 @@
"invalidFileExplanation": "Upload een PDF-bestand.",
"noFile": "Geen bestand",
"noFileExplanation": "Upload eerst een PDF-bestand.",
- "conversionSuccess": "PDF succesvol geconverteerd naar CBZ!",
+ "conversionSuccess": "PDF met succes geconverteerd naar CBZ!",
"conversionError": "Kan PDF niet converteren naar CBZ. Het bestand is mogelijk beschadigd."
}
},
@@ -448,7 +564,7 @@
"converting": "PDF's naar JSON converteren...",
"readError": "Fout bij lezen bestanden: {{message}}",
"creatingZip": "ZIP-bestand maken...",
- "success": "PDF's succesvol naar JSON geconverteerd! ZIP-download gestart.",
+ "success": "PDF's met succes naar JSON geconverteerd! ZIP-download gestart.",
"zipError": "Fout bij maken ZIP: {{message}}",
"workerError": "Worker-fout: {{message}}",
"getStarted": "Selecteer PDF-bestanden om te beginnen"
@@ -514,6 +630,51 @@
"retainPageLabels": "Originele pagina-labels van elk bestand behouden",
"retainPageLabelsHint": "Uit (standaard): de samengevoegde PDF gebruikt natuurlijke paginanummering 1–N. Aan: de originele pagina-labels van elk bestand blijven behouden (kan dubbele labels opleveren)."
},
+ "duplexCollate": {
+ "name": "Dubbelzijdige scan sorteren",
+ "subtitle": "Een PDF die in twee blokken is gescand (eerst alle voorkanten, dan alle achterkanten) in de juiste voor-/achterkantvolgorde zetten.",
+ "totalPagesLabel": "Totaal aantal pagina's:",
+ "splitAfterPageLabel": "Splitsen na pagina",
+ "splitAfterPagePlaceholder": "bijv. 50",
+ "backBlockOrderLabel": "Volgorde van achterkantblok",
+ "backBlockReverseOption": "Achterkantblok omkeren (gebruikelijk bij een omgedraaide stapel)",
+ "backBlockKeepOption": "Achterkantblok ongewijzigd laten",
+ "autoSplitButton": "Splitsing automatisch bepalen (helft)",
+ "exportGroupedLabel": "Gegroepeerde bestanden exporteren op lengte van het originele document",
+ "pagesPerDocumentLabel": "Pagina's per origineel document",
+ "groupedHelp": "Indien ingeschakeld is de uitvoer een ZIP-bestand met één PDF per gegroepeerd document.",
+ "collateButton": "PDF sorteren",
+ "invalidFileTitle": "Ongeldig bestand",
+ "invalidFileMessage": "Selecteer een PDF-bestand.",
+ "loadingPdf": "PDF laden...",
+ "loadErrorMessage": "Kan het PDF-bestand niet laden.",
+ "fileMeta": "{{size}} - {{count}} pagina's",
+ "oddPageWarning": "Oneven aantal pagina's ({{count}}). Een dubbelzijdige scan hoort een even aantal pagina's te hebben; mogelijk ontbreekt er een pagina of is er een te veel.",
+ "previewSummary": "Voorkantblok: {{frontCount}} pagina's, achterkantblok: {{backCount}} pagina's. Uitvoer: {{outputCount}} pagina's, in voor-/achterkantvolgorde.",
+ "previewMismatchWarning": "De blokken met voor- en achterkanten verschillen in lengte ({{frontCount}} voorkanten tegenover {{backCount}} achterkanten). De {{extraCount}} extra pagina's van het langste blok worden zonder tegenhanger achteraan toegevoegd.",
+ "missingFileTitle": "Bestand ontbreekt",
+ "missingFileMessage": "Upload een PDF met minstens 2 pagina's voordat je verwerkt.",
+ "invalidSplitTitle": "Ongeldig splitspunt",
+ "invalidSplitMessage": "Het splitspunt moet tussen pagina 1 en pagina {{maxSplit}} liggen.",
+ "unevenConfirmTitle": "Ongelijke blokken",
+ "unevenConfirmMessage": "Het voorkantblok heeft {{frontCount}} pagina's en het achterkantblok {{backCount}} pagina's.\nMeestal betekent dit dat een pagina dubbel is gescand of ontbreekt.\n\nDe {{extraCount}} pagina's zonder tegenhanger worden achteraan toegevoegd.\n\nToch doorgaan?",
+ "invalidGroupTitle": "Ongeldige groepsgrootte",
+ "invalidGroupMessage": "Voer een geldig aantal pagina's per origineel document in.",
+ "collatingLoader": "Dubbelzijdige scan sorteren...",
+ "successMessage": "Gesorteerde PDF is aangemaakt.",
+ "successGroupedMessage": "Sorteren voltooid. {{fileCount}} gegroepeerde bestanden aangemaakt.",
+ "processErrorMessage": "Kan de PDF niet sorteren.",
+ "howItWorksStep1Title": "Upload de scan",
+ "howItWorksStep1": "Sleep de ene PDF met eerst alle voorkanten en daarna alle achterkanten naar binnen.",
+ "howItWorksStep2Title": "Stel het splitspunt in",
+ "howItWorksStep2": "Geef aan waar het voorkantblok eindigt en kies of het achterkantblok omgekeerd moet worden.",
+ "howItWorksStep3Title": "Sorteer en download",
+ "howItWorksStep3": "Je krijgt één PDF met afwisselende pagina's, of een ZIP met een apart bestand per origineel document.",
+ "faqOddPagesQuestion": "Wat als mijn scan een oneven aantal pagina's heeft?",
+ "faqOddPagesAnswer": "Een dubbelzijdige scan hoort een even aantal pagina's te hebben. Is dat niet zo, dan is er waarschijnlijk een pagina dubbel gescand of overgeslagen. De tool waarschuwt je voor het verwerken, en pagina's zonder tegenhanger worden achteraan toegevoegd zodat er niets verloren gaat.",
+ "faqBackOrderQuestion": "Waarom zou ik het achterkantblok ongewijzigd laten?",
+ "faqBackOrderAnswer": "Dat hangt af van hoe de stapel bij de tweede ronde is ingevoerd. De hele stapel omdraaien levert achterkanten in omgekeerde volgorde op; dat is de standaardinstelling. Gaf je scanner ze in dezelfde volgorde als de voorkanten terug, kies dan om het blok ongewijzigd te laten."
+ },
"addAttachments": {
"name": "Bijlagen Toevoegen",
"subtitle": "Een of meer bestanden toevoegen aan een PDF."
@@ -942,7 +1103,7 @@
"tsaSectionTitle": "Tijdstempelserver (TSA)",
"selectTsa": "Selecteer een TSA-server",
"applyTimestamp": "Tijdstempel toepassen",
- "successMessage": "PDF succesvol voorzien van tijdstempel! Het tijdstempel kan in elke PDF-lezer worden geverifieerd."
+ "successMessage": "PDF met succes voorzien van tijdstempel! Het tijdstempel kan in elke PDF-lezer worden geverifieerd."
},
"emailToPdf": {
"name": "E-mail naar PDF",
@@ -1109,7 +1270,7 @@
"saveConfiguration": "Configuratie opslaan",
"savingConfiguration": "Configuratie opslaan...",
"savedTitle": "Opgeslagen",
- "savedMessage": "Configuratie succesvol opgeslagen!",
+ "savedMessage": "Configuratie met succes opgeslagen!",
"errorTitle": "Fout",
"failedSave": "Kon configuratie niet opslaan: {{message}}",
"emptyUrlTitle": "Lege URL",
diff --git a/public/locales/pt/common.json b/public/locales/pt/common.json
index eee7a88b..dd0ae36d 100644
--- a/public/locales/pt/common.json
+++ b/public/locales/pt/common.json
@@ -216,7 +216,7 @@
},
"analytics": {
"question": "Vocês usam cookies ou rastreamento?",
- "answer": "Usamos apenas o Simple Analytics para contar visitas de forma anônima. Sabemos quantos usuários nos visitam, mas nunca quem você é. O sistema respeita totalmente a GDPR."
+ "answer": "Não. O BentoPDF não rastreia você de forma alguma. Não usamos cookies, análises nem scripts de rastreamento, nem mesmo no site ao vivo bentopdf.com. Nunca sabemos quem você é ou o que faz com as ferramentas."
}
},
"testimonials": {
diff --git a/public/locales/pt/tools.json b/public/locales/pt/tools.json
index 940c97e9..99705801 100644
--- a/public/locales/pt/tools.json
+++ b/public/locales/pt/tools.json
@@ -55,6 +55,122 @@
"name": "Editor de PDF",
"subtitle": "Anotar, destacar, redigir, comentar, adicionar formas/imagens, pesquisar e visualizar PDFs."
},
+ "editPdfText": {
+ "name": "Editar texto do PDF",
+ "subtitle": "Clique em qualquer parágrafo e edite-o no local com reorganização do texto em tempo real, fontes e estilos.",
+ "saved": "PDF salvo com sucesso! ({{size}} KB)",
+ "failedToLoad": "Não foi possível carregar o editor de texto PDF.",
+ "howToUpload": "Enviar PDF",
+ "howToClick": "Clique em um parágrafo",
+ "howToRestyle": "Reestilize tudo",
+ "relatedPdfEditor": "Anote e marque PDFs",
+ "relatedSignPdf": "Desenhe, digite ou envie uma assinatura",
+ "relatedWatermark": "Aplique texto ou imagens",
+ "relatedStamps": "Coloque carimbos nas páginas",
+ "relatedCompress": "Reduza o tamanho do arquivo",
+ "relatedOrganizeName": "Organizar PDF",
+ "relatedOrganize": "Reordene e gerencie páginas",
+ "open": "Abrir",
+ "export": "Exportar",
+ "filterAll": "Tudo",
+ "filterText": "Texto",
+ "filterImages": "Imagens",
+ "filterShapes": "Formas",
+ "addText": "Adicionar texto",
+ "langAuto": "Automático",
+ "change": "Alterar",
+ "ignore": "Ignorar",
+ "next": "Próximo",
+ "replace": "Substituir",
+ "replaceAll": "Substituir tudo",
+ "emptyState": "Abra um PDF para editar seu texto, imagens e formas.",
+ "choosePdf": "Escolher um PDF…",
+ "font": "Fonte",
+ "systemFonts": "Fontes do sistema",
+ "color": "Cor",
+ "alignment": "Alinhamento",
+ "style": "Estilo",
+ "lists": "Listas",
+ "outline": "Contorno",
+ "spacing": "Espaçamento",
+ "direction": "Direção",
+ "dirAuto": "Automático",
+ "pathStroke": "Contorno do traçado",
+ "tipBackToUpload": "Voltar ao envio",
+ "tipOpenAnotherPdf": "Abrir outro PDF",
+ "tipExportS": "Exportar (⌘S)",
+ "tipUndoZ": "Desfazer (⌘Z)",
+ "tipRedoZ": "Refazer (⇧⌘Z)",
+ "tipFileDetails": "Detalhes do arquivo",
+ "tipEditExistingContent": "Editar conteúdo existente",
+ "tipWhatTheEditToolTouchesNarrowTheScopeOnBus": "O que a ferramenta Editar afeta — restrinja o alcance em páginas cheias",
+ "tipClickThePageToAddATextBox": "Clique na página para adicionar uma caixa de texto",
+ "tipSpellCheckLanguage": "Idioma do corretor ortográfico",
+ "tipFindAndReplace": "Localizar e substituir",
+ "tipAddAnImage": "Adicionar uma imagem",
+ "tipCopyThisPageSTextInReadingOrder": "Copiar o texto desta página na ordem de leitura",
+ "tipCheckTheSpellingOfThisPage": "Verificar a ortografia desta página",
+ "tipShowOrHideTheFormatPanel": "Mostrar ou ocultar o painel de formatação",
+ "phReplaceWith": "Substituir por",
+ "tipAddToYourDictionary": "Adicionar ao dicionário",
+ "tipClose": "Fechar",
+ "phFind": "Localizar",
+ "tipPreviousMatch": "Ocorrência anterior",
+ "tipNextMatch": "Próxima ocorrência",
+ "tipRotateLeft90": "Girar 90° à esquerda",
+ "tipRotateRight90": "Girar 90° à direita",
+ "tipFlipHorizontal": "Inverter horizontalmente",
+ "tipFlipVertical": "Inverter verticalmente",
+ "tipReplaceThisImageKeepingItsPlacement": "Substituir esta imagem mantendo a posição",
+ "tipAlternateTextForAccessibilityTaggedPdfs": "Texto alternativo para acessibilidade (PDFs marcados)",
+ "tipBringToFront": "Trazer para a frente",
+ "tipSendToBack": "Enviar para trás",
+ "tipDuplicate": "Duplicar",
+ "tipDelete": "Excluir",
+ "tipAlignLeftEdges": "Alinhar bordas esquerdas",
+ "tipAlignHorizontalCenters": "Centralizar horizontalmente",
+ "tipAlignRightEdges": "Alinhar bordas direitas",
+ "tipAlignTops": "Alinhar bordas superiores",
+ "tipAlignVerticalCenters": "Centralizar verticalmente",
+ "tipAlignBottoms": "Alinhar bordas inferiores",
+ "tipDistributeHorizontally": "Distribuir horizontalmente",
+ "tipDistributeVertically": "Distribuir verticalmente",
+ "tipFontSize": "Tamanho da fonte",
+ "tipListFontsInstalledOnThisDeviceChromium": "Listar as fontes instaladas neste dispositivo (Chromium)",
+ "tipCustomTextColor": "Cor de texto personalizada",
+ "tipAlignLeft": "Alinhar à esquerda",
+ "tipAlignCenter": "Centralizar",
+ "tipAlignRight": "Alinhar à direita",
+ "tipJustify": "Justificar",
+ "tipBold": "Negrito",
+ "tipItalic": "Itálico",
+ "tipUnderline": "Sublinhado",
+ "tipStrikethrough": "Tachado",
+ "tipSuperscript": "Sobrescrito",
+ "tipSubscript": "Subscrito",
+ "tipToggleBulletedItem": "Alternar item com marcador",
+ "tipToggleNumberedItem": "Alternar item numerado",
+ "tipNumberedListMarkerStyle": "Estilo de marcador de listas numeradas",
+ "tipDecreaseListIndentTab": "Diminuir recuo (⇧Tab)",
+ "tipIncreaseListIndentTab": "Aumentar recuo (Tab)",
+ "tipTextOutlineColor": "Cor do contorno do texto",
+ "tipOutlineWidth0None": "Espessura do contorno (0 = nenhum)",
+ "tipFromContent": "Do conteúdo",
+ "tipLeftToRight": "Da esquerda para a direita",
+ "tipRightToLeft": "Da direita para a esquerda",
+ "tipStrokeColor": "Cor do traço",
+ "tipStrokeWidth": "Espessura do traço",
+ "tipZoomPresets": "Predefinições de zoom",
+ "tipZoomOut": "Reduzir zoom",
+ "tipZoomIn": "Ampliar zoom",
+ "tipFitPage": "Ajustar página",
+ "tipPreviousPage": "Página anterior",
+ "tipTypeAPageNumberAndPressEnter": "Digite um número de página e pressione Enter",
+ "tipNextPage": "Próxima página",
+ "tipDocumentProperties": "Propriedades do documento",
+ "tipHideBar": "Ocultar barra",
+ "tipShowBar": "Mostrar barra"
+ },
"jpgToPdf": {
"name": "JPG para PDF",
"subtitle": "Crie um PDF a partir de uma ou mais imagens JPG."
@@ -514,6 +630,51 @@
"retainPageLabels": "Manter os rótulos de página originais de cada ficheiro",
"retainPageLabelsHint": "Desligado (padrão): o PDF mesclado usa numeração natural 1–N. Ligado: os rótulos de página originais de cada ficheiro são preservados (pode produzir rótulos duplicados)."
},
+ "duplexCollate": {
+ "name": "Intercalar digitalização frente e verso",
+ "subtitle": "Reorganize um PDF digitalizado em dois blocos (primeiro todas as frentes, depois todos os versos) na ordem frente/verso correta.",
+ "totalPagesLabel": "Total de páginas:",
+ "splitAfterPageLabel": "Dividir após a página",
+ "splitAfterPagePlaceholder": "ex.: 50",
+ "backBlockOrderLabel": "Ordem do bloco de versos",
+ "backBlockReverseOption": "Inverter o bloco de versos (comum quando a pilha é virada)",
+ "backBlockKeepOption": "Manter o bloco de versos como está",
+ "autoSplitButton": "Detectar divisão automaticamente (metade)",
+ "exportGroupedLabel": "Exportar arquivos agrupados pelo tamanho do documento original",
+ "pagesPerDocumentLabel": "Páginas por documento original",
+ "groupedHelp": "Se ativado, a saída é um ZIP contendo um PDF para cada documento agrupado.",
+ "collateButton": "Intercalar PDF",
+ "invalidFileTitle": "Arquivo inválido",
+ "invalidFileMessage": "Selecione um arquivo PDF.",
+ "loadingPdf": "Carregando PDF...",
+ "loadErrorMessage": "Falha ao carregar o arquivo PDF.",
+ "fileMeta": "{{size}} - {{count}} páginas",
+ "oddPageWarning": "Número ímpar de páginas ({{count}}). Uma digitalização frente e verso deve ter um número par de páginas; pode haver uma página faltando ou sobrando.",
+ "previewSummary": "Bloco de frentes: {{frontCount}} página(s), bloco de versos: {{backCount}} página(s). Saída: {{outputCount}} página(s), na sequência frente/verso.",
+ "previewMismatchWarning": "Os blocos de frentes e versos têm tamanhos diferentes ({{frontCount}} frentes contra {{backCount}} versos). As {{extraCount}} página(s) excedentes do bloco maior serão anexadas sem par no final.",
+ "missingFileTitle": "Arquivo ausente",
+ "missingFileMessage": "Envie um PDF com pelo menos 2 páginas antes de processar.",
+ "invalidSplitTitle": "Ponto de divisão inválido",
+ "invalidSplitMessage": "O ponto de divisão deve estar entre a página 1 e a página {{maxSplit}}.",
+ "unevenConfirmTitle": "Blocos desiguais",
+ "unevenConfirmMessage": "O bloco de frentes tem {{frontCount}} página(s) e o de versos tem {{backCount}} página(s).\nNormalmente isso significa que uma página foi digitalizada duas vezes ou está faltando.\n\nAs {{extraCount}} página(s) sem par serão anexadas no final.\n\nContinuar mesmo assim?",
+ "invalidGroupTitle": "Tamanho de grupo inválido",
+ "invalidGroupMessage": "Informe um número válido de páginas por documento original.",
+ "collatingLoader": "Intercalando a digitalização frente e verso...",
+ "successMessage": "PDF intercalado gerado com sucesso.",
+ "successGroupedMessage": "Intercalação concluída. {{fileCount}} arquivo(s) agrupado(s) gerado(s).",
+ "processErrorMessage": "Falha ao intercalar o PDF.",
+ "howItWorksStep1Title": "Envie a digitalização",
+ "howItWorksStep1": "Solte o PDF único que contém todas as frentes seguidas de todos os versos.",
+ "howItWorksStep2Title": "Defina o ponto de divisão",
+ "howItWorksStep2": "Indique onde termina o bloco de frentes e escolha se o bloco de versos precisa ser invertido.",
+ "howItWorksStep3Title": "Intercale e baixe",
+ "howItWorksStep3": "Receba um PDF intercalado ou um ZIP com um arquivo separado para cada documento original.",
+ "faqOddPagesQuestion": "E se a minha digitalização tiver um número ímpar de páginas?",
+ "faqOddPagesAnswer": "Uma digitalização frente e verso deve ter um número par de páginas. Se não tiver, provavelmente uma página foi digitalizada duas vezes ou ficou de fora. A ferramenta avisa antes de processar, e as páginas sem par são anexadas no final para que nada se perca.",
+ "faqBackOrderQuestion": "Por que eu manteria o bloco de versos como está?",
+ "faqBackOrderAnswer": "Depende de como a pilha foi inserida na segunda passagem. Virar a pilha inteira produz versos em ordem invertida, que é a opção padrão. Se o seu scanner os devolveu na mesma ordem das frentes, escolha manter o bloco como está."
+ },
"addAttachments": {
"name": "Adicionar Anexos",
"subtitle": "Incorpore um ou mais arquivos dentro do seu PDF."
diff --git a/public/locales/ru/common.json b/public/locales/ru/common.json
index fd32f7b2..4fb13558 100644
--- a/public/locales/ru/common.json
+++ b/public/locales/ru/common.json
@@ -216,7 +216,7 @@
},
"analytics": {
"question": "Вы используете cookies или аналитику для слежки за мной?",
- "answer": "Мы уважаем вашу приватность. BentoPDF не отслеживает персональную информацию. Мы используем Simple Analytics исключительно для анонимного подсчета посещений нашего сайта. Это означает, что мы знаем, как много посетителей зашло на наш сайт, но мы не знаем, кто они. Simple Analytics полностью соответствует GDPR."
+ "answer": "Нет. BentoPDF вас никак не отслеживает. Мы не используем файлы cookie, аналитику или трекинговые скрипты — даже на действующем сайте bentopdf.com. Мы никогда не знаем, кто вы и что вы делаете с инструментами."
}
},
"testimonials": {
diff --git a/public/locales/ru/tools.json b/public/locales/ru/tools.json
index 72334b9c..be9cac07 100644
--- a/public/locales/ru/tools.json
+++ b/public/locales/ru/tools.json
@@ -55,6 +55,122 @@
"name": "Редактор PDF",
"subtitle": "Добавить аннотации, выделения и комментарии, вымарать текст, добавить фигуры/изображения, найти в тексте, просмотреть документ."
},
+ "editPdfText": {
+ "name": "Редактирование текста PDF",
+ "subtitle": "Нажать на любой абзац и отредактировать его на месте — с мгновенной перекомпоновкой текста, шрифтами и стилями.",
+ "saved": "PDF успешно сохранён! ({{size}} КБ)",
+ "failedToLoad": "Не удалось загрузить редактор текста PDF.",
+ "howToUpload": "Загрузить PDF",
+ "howToClick": "Нажмите на абзац",
+ "howToRestyle": "Измените стиль",
+ "relatedPdfEditor": "Комментируйте и размечайте PDF",
+ "relatedSignPdf": "Нарисуйте, введите или загрузите подпись",
+ "relatedWatermark": "Нанесите текст или изображения",
+ "relatedStamps": "Размещайте штампы на страницах",
+ "relatedCompress": "Уменьшите размер файла",
+ "relatedOrganizeName": "Упорядочить PDF",
+ "relatedOrganize": "Меняйте порядок и управляйте страницами",
+ "open": "Открыть",
+ "export": "Экспорт",
+ "filterAll": "Все",
+ "filterText": "Текст",
+ "filterImages": "Изображения",
+ "filterShapes": "Фигуры",
+ "addText": "Добавить текст",
+ "langAuto": "Авто",
+ "change": "Изменить",
+ "ignore": "Пропустить",
+ "next": "Далее",
+ "replace": "Заменить",
+ "replaceAll": "Заменить все",
+ "emptyState": "Откройте PDF, чтобы редактировать текст, изображения и фигуры.",
+ "choosePdf": "Выберите PDF…",
+ "font": "Шрифт",
+ "systemFonts": "Системные шрифты",
+ "color": "Цвет",
+ "alignment": "Выравнивание",
+ "style": "Стиль",
+ "lists": "Списки",
+ "outline": "Обводка",
+ "spacing": "Интервал",
+ "direction": "Направление",
+ "dirAuto": "Авто",
+ "pathStroke": "Обводка контура",
+ "tipBackToUpload": "Назад к загрузке",
+ "tipOpenAnotherPdf": "Открыть другой PDF",
+ "tipExportS": "Экспорт (⌘S)",
+ "tipUndoZ": "Отменить (⌘Z)",
+ "tipRedoZ": "Повторить (⇧⌘Z)",
+ "tipFileDetails": "Сведения о файле",
+ "tipEditExistingContent": "Редактировать существующее содержимое",
+ "tipWhatTheEditToolTouchesNarrowTheScopeOnBus": "На что действует инструмент редактирования — сузьте область на насыщенных страницах",
+ "tipClickThePageToAddATextBox": "Нажмите на страницу, чтобы добавить текстовое поле",
+ "tipSpellCheckLanguage": "Язык проверки орфографии",
+ "tipFindAndReplace": "Найти и заменить",
+ "tipAddAnImage": "Добавить изображение",
+ "tipCopyThisPageSTextInReadingOrder": "Скопировать текст страницы в порядке чтения",
+ "tipCheckTheSpellingOfThisPage": "Проверить орфографию на этой странице",
+ "tipShowOrHideTheFormatPanel": "Показать или скрыть панель форматирования",
+ "phReplaceWith": "Заменить на",
+ "tipAddToYourDictionary": "Добавить в словарь",
+ "tipClose": "Закрыть",
+ "phFind": "Найти",
+ "tipPreviousMatch": "Предыдущее совпадение",
+ "tipNextMatch": "Следующее совпадение",
+ "tipRotateLeft90": "Повернуть на 90° влево",
+ "tipRotateRight90": "Повернуть на 90° вправо",
+ "tipFlipHorizontal": "Отразить по горизонтали",
+ "tipFlipVertical": "Отразить по вертикали",
+ "tipReplaceThisImageKeepingItsPlacement": "Заменить изображение, сохранив положение",
+ "tipAlternateTextForAccessibilityTaggedPdfs": "Альтернативный текст для доступности (теговые PDF)",
+ "tipBringToFront": "На передний план",
+ "tipSendToBack": "На задний план",
+ "tipDuplicate": "Дублировать",
+ "tipDelete": "Удалить",
+ "tipAlignLeftEdges": "Выровнять по левым краям",
+ "tipAlignHorizontalCenters": "Центрировать по горизонтали",
+ "tipAlignRightEdges": "Выровнять по правым краям",
+ "tipAlignTops": "Выровнять по верхним краям",
+ "tipAlignVerticalCenters": "Центрировать по вертикали",
+ "tipAlignBottoms": "Выровнять по нижним краям",
+ "tipDistributeHorizontally": "Распределить по горизонтали",
+ "tipDistributeVertically": "Распределить по вертикали",
+ "tipFontSize": "Размер шрифта",
+ "tipListFontsInstalledOnThisDeviceChromium": "Показать шрифты, установленные на этом устройстве (Chromium)",
+ "tipCustomTextColor": "Свой цвет текста",
+ "tipAlignLeft": "По левому краю",
+ "tipAlignCenter": "По центру",
+ "tipAlignRight": "По правому краю",
+ "tipJustify": "По ширине",
+ "tipBold": "Полужирный",
+ "tipItalic": "Курсив",
+ "tipUnderline": "Подчёркнутый",
+ "tipStrikethrough": "Зачёркнутый",
+ "tipSuperscript": "Верхний индекс",
+ "tipSubscript": "Нижний индекс",
+ "tipToggleBulletedItem": "Маркированный пункт",
+ "tipToggleNumberedItem": "Нумерованный пункт",
+ "tipNumberedListMarkerStyle": "Стиль маркера нумерованных списков",
+ "tipDecreaseListIndentTab": "Уменьшить отступ (⇧Tab)",
+ "tipIncreaseListIndentTab": "Увеличить отступ (Tab)",
+ "tipTextOutlineColor": "Цвет контура текста",
+ "tipOutlineWidth0None": "Толщина контура (0 = нет)",
+ "tipFromContent": "Из содержимого",
+ "tipLeftToRight": "Слева направо",
+ "tipRightToLeft": "Справа налево",
+ "tipStrokeColor": "Цвет обводки",
+ "tipStrokeWidth": "Толщина обводки",
+ "tipZoomPresets": "Предустановки масштаба",
+ "tipZoomOut": "Уменьшить",
+ "tipZoomIn": "Увеличить",
+ "tipFitPage": "По размеру страницы",
+ "tipPreviousPage": "Предыдущая страница",
+ "tipTypeAPageNumberAndPressEnter": "Введите номер страницы и нажмите Enter",
+ "tipNextPage": "Следующая страница",
+ "tipDocumentProperties": "Свойства документа",
+ "tipHideBar": "Скрыть панель",
+ "tipShowBar": "Показать панель"
+ },
"jpgToPdf": {
"name": "JPG в PDF",
"subtitle": "Создать PDF из JPG, JPEG или JPEG2000 (JP2/JPX)."
@@ -514,6 +630,51 @@
"retainPageLabels": "Сохранять исходные метки страниц из каждого файла",
"retainPageLabelsHint": "Выкл (по умолчанию): объединённый PDF использует естественную нумерацию страниц 1–N. Вкл: исходные метки страниц каждого файла сохраняются (могут появиться дубликаты)."
},
+ "duplexCollate": {
+ "name": "Сортировка двустороннего сканирования",
+ "subtitle": "Переупорядочивает PDF, отсканированный двумя блоками (сначала все лицевые стороны, затем все обороты), в правильный порядок лицевая/оборот.",
+ "totalPagesLabel": "Всего страниц:",
+ "splitAfterPageLabel": "Разделить после страницы",
+ "splitAfterPagePlaceholder": "напр. 50",
+ "backBlockOrderLabel": "Порядок блока оборотов",
+ "backBlockReverseOption": "Обратить блок оборотов (обычно при перевёрнутой стопке)",
+ "backBlockKeepOption": "Оставить блок оборотов без изменений",
+ "autoSplitButton": "Определить разделение автоматически (половина)",
+ "exportGroupedLabel": "Экспортировать файлы группами по длине исходного документа",
+ "pagesPerDocumentLabel": "Страниц в исходном документе",
+ "groupedHelp": "Если включено, результатом будет ZIP-архив с отдельным PDF для каждого сгруппированного документа.",
+ "collateButton": "Собрать PDF",
+ "invalidFileTitle": "Недопустимый файл",
+ "invalidFileMessage": "Выберите файл PDF.",
+ "loadingPdf": "Загрузка PDF...",
+ "loadErrorMessage": "Не удалось загрузить файл PDF.",
+ "fileMeta": "{{size}} - {{count}} стр.",
+ "oddPageWarning": "Нечётное число страниц ({{count}}). При двустороннем сканировании число страниц должно быть чётным; возможно, страница пропущена или лишняя.",
+ "previewSummary": "Блок лицевых сторон: {{frontCount}} стр., блок оборотов: {{backCount}} стр. Результат: {{outputCount}} стр. в порядке лицевая/оборот.",
+ "previewMismatchWarning": "Длина блоков лицевых и оборотных сторон различается ({{frontCount}} лицевых против {{backCount}} оборотов). Лишние страницы ({{extraCount}}) из более длинного блока будут добавлены без пары в конец.",
+ "missingFileTitle": "Файл не выбран",
+ "missingFileMessage": "Перед обработкой загрузите PDF не менее чем с 2 страницами.",
+ "invalidSplitTitle": "Недопустимая точка разделения",
+ "invalidSplitMessage": "Точка разделения должна быть между страницей 1 и страницей {{maxSplit}}.",
+ "unevenConfirmTitle": "Неравные блоки",
+ "unevenConfirmMessage": "В блоке лицевых сторон {{frontCount}} стр., в блоке оборотов — {{backCount}} стр.\nОбычно это означает, что страница отсканирована дважды или отсутствует.\n\nСтраницы без пары ({{extraCount}}) будут добавлены в конец.\n\nВсё равно продолжить?",
+ "invalidGroupTitle": "Недопустимый размер группы",
+ "invalidGroupMessage": "Введите допустимое число страниц в исходном документе.",
+ "collatingLoader": "Сборка двустороннего сканирования...",
+ "successMessage": "Собранный PDF успешно создан.",
+ "successGroupedMessage": "Сборка завершена. Создано файлов: {{fileCount}}.",
+ "processErrorMessage": "Не удалось собрать PDF.",
+ "howItWorksStep1Title": "Загрузите скан",
+ "howItWorksStep1": "Перетащите один PDF, в котором сначала идут все лицевые стороны, а затем все обороты.",
+ "howItWorksStep2Title": "Задайте точку разделения",
+ "howItWorksStep2": "Укажите, где заканчивается блок лицевых сторон, и выберите, нужно ли обращать блок оборотов.",
+ "howItWorksStep3Title": "Соберите и скачайте",
+ "howItWorksStep3": "Вы получите один PDF с чередованием страниц или ZIP-архив с отдельным файлом для каждого исходного документа.",
+ "faqOddPagesQuestion": "Что делать, если в скане нечётное число страниц?",
+ "faqOddPagesAnswer": "При двустороннем сканировании число страниц должно быть чётным. Если это не так, страница, скорее всего, отсканирована дважды или пропущена. Инструмент предупредит вас перед обработкой, а страницы без пары будут добавлены в конец, чтобы ничего не потерялось.",
+ "faqBackOrderQuestion": "Зачем оставлять блок оборотов без изменений?",
+ "faqBackOrderAnswer": "Это зависит от того, как стопка подавалась на втором проходе. Если перевернуть всю стопку целиком, обороты идут в обратном порядке — это вариант по умолчанию. Если сканер выдал их в том же порядке, что и лицевые стороны, выберите вариант «оставить без изменений»."
+ },
"addAttachments": {
"name": "Добавление вложений",
"subtitle": "Вложить один или несколько файлов в документ."
diff --git a/public/locales/sk/common.json b/public/locales/sk/common.json
index 8b6e746d..52022a6e 100644
--- a/public/locales/sk/common.json
+++ b/public/locales/sk/common.json
@@ -216,7 +216,7 @@
},
"analytics": {
"question": "Používate súbory cookie alebo analytiku na moje sledovanie?",
- "answer": "Záleží nám na vašom súkromí. BentoPDF nesleduje osobné údaje. Nástroj Simple Analytics využívame výhradne na zobrazenie anonymného počtu návštev. Znamená to, že môžeme vedieť, koľko používateľov navštívilo našu stránku, ale nikdy nevieme, kto ste. Simple Analytics je plne v súlade s GDPR a rešpektuje vaše súkromie."
+ "answer": "Nie. BentoPDF vás vôbec nesleduje. Nepoužívame cookies, analytiku ani sledovacie skripty – ani na živej webovej stránke bentopdf.com. Nikdy nevieme, kto ste ani čo s nástrojmi robíte."
}
},
"testimonials": {
diff --git a/public/locales/sk/tools.json b/public/locales/sk/tools.json
index 6b4621cb..b3af13b1 100644
--- a/public/locales/sk/tools.json
+++ b/public/locales/sk/tools.json
@@ -55,6 +55,122 @@
"name": "PDF Editor",
"subtitle": "Anotovať, zvýrazňovať, redigovať, komentovať, pridávať tvary/obrázky, vyhľadávať a prezerať PDF."
},
+ "editPdfText": {
+ "name": "Upraviť text PDF",
+ "subtitle": "Kliknite na ľubovoľný odsek a upravte ho priamo na mieste s okamžitým preformátovaním textu, písmami a štýlmi.",
+ "saved": "PDF bolo úspešne uložené! ({{size}} KB)",
+ "failedToLoad": "Nepodarilo sa načítať textový editor PDF.",
+ "howToUpload": "Nahrať PDF",
+ "howToClick": "Kliknite na odsek",
+ "howToRestyle": "Zmeňte štýl čohokoľvek",
+ "relatedPdfEditor": "Komentujte a označujte PDF",
+ "relatedSignPdf": "Nakreslite, napíšte alebo nahrajte podpis",
+ "relatedWatermark": "Vložte text alebo obrázky",
+ "relatedStamps": "Umiestnite pečiatky na strany",
+ "relatedCompress": "Zmenšite veľkosť súboru",
+ "relatedOrganizeName": "Usporiadať PDF",
+ "relatedOrganize": "Zmeňte poradie a spravujte strany",
+ "open": "Otvoriť",
+ "export": "Exportovať",
+ "filterAll": "Všetko",
+ "filterText": "Text",
+ "filterImages": "Obrázky",
+ "filterShapes": "Tvary",
+ "addText": "Pridať text",
+ "langAuto": "Automaticky",
+ "change": "Zmeniť",
+ "ignore": "Ignorovať",
+ "next": "Ďalej",
+ "replace": "Nahradiť",
+ "replaceAll": "Nahradiť všetko",
+ "emptyState": "Otvorte PDF a upravte jeho text, obrázky a tvary.",
+ "choosePdf": "Vyberte PDF…",
+ "font": "Písmo",
+ "systemFonts": "Systémové písma",
+ "color": "Farba",
+ "alignment": "Zarovnanie",
+ "style": "Štýl",
+ "lists": "Zoznamy",
+ "outline": "Obrys",
+ "spacing": "Rozostup",
+ "direction": "Smer",
+ "dirAuto": "Automaticky",
+ "pathStroke": "Obrys cesty",
+ "tipBackToUpload": "Späť na nahrávanie",
+ "tipOpenAnotherPdf": "Otvoriť ďalšie PDF",
+ "tipExportS": "Exportovať (⌘S)",
+ "tipUndoZ": "Späť (⌘Z)",
+ "tipRedoZ": "Znova (⇧⌘Z)",
+ "tipFileDetails": "Podrobnosti o súbore",
+ "tipEditExistingContent": "Upraviť existujúci obsah",
+ "tipWhatTheEditToolTouchesNarrowTheScopeOnBus": "Čoho sa nástroj Upraviť dotýka – zúžte rozsah na zaplnených stranách",
+ "tipClickThePageToAddATextBox": "Kliknutím na stranu pridáte textové pole",
+ "tipSpellCheckLanguage": "Jazyk kontroly pravopisu",
+ "tipFindAndReplace": "Nájsť a nahradiť",
+ "tipAddAnImage": "Pridať obrázok",
+ "tipCopyThisPageSTextInReadingOrder": "Skopírovať text tejto strany v poradí čítania",
+ "tipCheckTheSpellingOfThisPage": "Skontrolovať pravopis na tejto strane",
+ "tipShowOrHideTheFormatPanel": "Zobraziť alebo skryť panel formátovania",
+ "phReplaceWith": "Nahradiť čím",
+ "tipAddToYourDictionary": "Pridať do slovníka",
+ "tipClose": "Zavrieť",
+ "phFind": "Nájsť",
+ "tipPreviousMatch": "Predchádzajúca zhoda",
+ "tipNextMatch": "Ďalšia zhoda",
+ "tipRotateLeft90": "Otočiť o 90° doľava",
+ "tipRotateRight90": "Otočiť o 90° doprava",
+ "tipFlipHorizontal": "Prevrátiť vodorovne",
+ "tipFlipVertical": "Prevrátiť zvisle",
+ "tipReplaceThisImageKeepingItsPlacement": "Nahradiť tento obrázok a zachovať jeho umiestnenie",
+ "tipAlternateTextForAccessibilityTaggedPdfs": "Alternatívny text pre prístupnosť (označkované PDF)",
+ "tipBringToFront": "Presunúť dopredu",
+ "tipSendToBack": "Presunúť dozadu",
+ "tipDuplicate": "Duplikovať",
+ "tipDelete": "Odstrániť",
+ "tipAlignLeftEdges": "Zarovnať ľavé okraje",
+ "tipAlignHorizontalCenters": "Vycentrovať vodorovne",
+ "tipAlignRightEdges": "Zarovnať pravé okraje",
+ "tipAlignTops": "Zarovnať horné okraje",
+ "tipAlignVerticalCenters": "Vycentrovať zvisle",
+ "tipAlignBottoms": "Zarovnať dolné okraje",
+ "tipDistributeHorizontally": "Rozmiestniť vodorovne",
+ "tipDistributeVertically": "Rozmiestniť zvisle",
+ "tipFontSize": "Veľkosť písma",
+ "tipListFontsInstalledOnThisDeviceChromium": "Zobraziť písma nainštalované v tomto zariadení (Chromium)",
+ "tipCustomTextColor": "Vlastná farba textu",
+ "tipAlignLeft": "Zarovnať doľava",
+ "tipAlignCenter": "Na stred",
+ "tipAlignRight": "Zarovnať doprava",
+ "tipJustify": "Do bloku",
+ "tipBold": "Tučné",
+ "tipItalic": "Kurzíva",
+ "tipUnderline": "Podčiarknuté",
+ "tipStrikethrough": "Prečiarknuté",
+ "tipSuperscript": "Horný index",
+ "tipSubscript": "Dolný index",
+ "tipToggleBulletedItem": "Prepnúť odrážku",
+ "tipToggleNumberedItem": "Prepnúť číslovanú položku",
+ "tipNumberedListMarkerStyle": "Štýl značky číslovaných zoznamov",
+ "tipDecreaseListIndentTab": "Zmenšiť odsadenie (⇧Tab)",
+ "tipIncreaseListIndentTab": "Zväčšiť odsadenie (Tab)",
+ "tipTextOutlineColor": "Farba obrysu textu",
+ "tipOutlineWidth0None": "Šírka obrysu (0 = žiadny)",
+ "tipFromContent": "Z obsahu",
+ "tipLeftToRight": "Zľava doprava",
+ "tipRightToLeft": "Sprava doľava",
+ "tipStrokeColor": "Farba ťahu",
+ "tipStrokeWidth": "Šírka ťahu",
+ "tipZoomPresets": "Predvoľby priblíženia",
+ "tipZoomOut": "Oddialiť",
+ "tipZoomIn": "Priblížiť",
+ "tipFitPage": "Prispôsobiť stranu",
+ "tipPreviousPage": "Predchádzajúca strana",
+ "tipTypeAPageNumberAndPressEnter": "Zadajte číslo strany a stlačte Enter",
+ "tipNextPage": "Ďalšia strana",
+ "tipDocumentProperties": "Vlastnosti dokumentu",
+ "tipHideBar": "Skryť panel",
+ "tipShowBar": "Zobraziť panel"
+ },
"jpgToPdf": {
"name": "JPG do PDF",
"subtitle": "Vytvoriť PDF z obrázkov JPG, JPEG a JPEG2000 (JP2/JPX)."
@@ -514,6 +630,51 @@
"retainPageLabels": "Zachovať pôvodné označenia strán z každého súboru",
"retainPageLabelsHint": "Vypnuté (predvolené): spojené PDF používa prirodzené číslovanie strán 1–N. Zapnuté: zachovajú sa pôvodné označenia strán každého súboru (môže produkovať duplicitné označenia)."
},
+ "duplexCollate": {
+ "name": "Usporiadať obojstranné skenovanie",
+ "subtitle": "Zoradí PDF naskenované v dvoch blokoch (najprv všetky predné strany, potom všetky zadné) do správneho poradia predná/zadná.",
+ "totalPagesLabel": "Počet strán:",
+ "splitAfterPageLabel": "Rozdeliť po strane",
+ "splitAfterPagePlaceholder": "napr. 50",
+ "backBlockOrderLabel": "Poradie bloku zadných strán",
+ "backBlockReverseOption": "Obrátiť blok zadných strán (bežné pri otočenom stohu)",
+ "backBlockKeepOption": "Ponechať blok zadných strán bez zmeny",
+ "autoSplitButton": "Zistiť rozdelenie automaticky (polovica)",
+ "exportGroupedLabel": "Exportovať zoskupené súbory podľa dĺžky pôvodného dokumentu",
+ "pagesPerDocumentLabel": "Strán na pôvodný dokument",
+ "groupedHelp": "Ak je táto možnosť zapnutá, výstupom je ZIP obsahujúci jedno PDF pre každý zoskupený dokument.",
+ "collateButton": "Usporiadať PDF",
+ "invalidFileTitle": "Neplatný súbor",
+ "invalidFileMessage": "Vyberte súbor PDF.",
+ "loadingPdf": "Načítava sa PDF...",
+ "loadErrorMessage": "Súbor PDF sa nepodarilo načítať.",
+ "fileMeta": "{{size}} - {{count}} strán",
+ "oddPageWarning": "Nepárny počet strán ({{count}}). Obojstranné skenovanie by malo mať párny počet strán; možno chýba strana alebo je jedna navyše.",
+ "previewSummary": "Blok predných strán: {{frontCount}}, blok zadných strán: {{backCount}}. Výstup: {{outputCount}} strán v poradí predná/zadná.",
+ "previewMismatchWarning": "Bloky predných a zadných strán majú rôznu dĺžku ({{frontCount}} predných oproti {{backCount}} zadným). {{extraCount}} strán navyše z dlhšieho bloku sa pridá bez páru na koniec.",
+ "missingFileTitle": "Chýbajúci súbor",
+ "missingFileMessage": "Pred spracovaním nahrajte PDF s aspoň 2 stranami.",
+ "invalidSplitTitle": "Neplatný bod rozdelenia",
+ "invalidSplitMessage": "Bod rozdelenia musí byť medzi stranou 1 a stranou {{maxSplit}}.",
+ "unevenConfirmTitle": "Nerovnaké bloky",
+ "unevenConfirmMessage": "Blok predných strán má {{frontCount}} strán a blok zadných strán {{backCount}} strán.\nZvyčajne to znamená, že sa niektorá strana naskenovala dvakrát alebo chýba.\n\n{{extraCount}} strán bez páru sa pridá na koniec.\n\nChcete napriek tomu pokračovať?",
+ "invalidGroupTitle": "Neplatná veľkosť skupiny",
+ "invalidGroupMessage": "Zadajte platný počet strán na pôvodný dokument.",
+ "collatingLoader": "Usporadúva sa obojstranné skenovanie...",
+ "successMessage": "Usporiadané PDF bolo úspešne vytvorené.",
+ "successGroupedMessage": "Usporiadanie dokončené. Vytvorených {{fileCount}} zoskupených súborov.",
+ "processErrorMessage": "PDF sa nepodarilo usporiadať.",
+ "howItWorksStep1Title": "Nahrajte sken",
+ "howItWorksStep1": "Vložte jedno PDF, ktoré obsahuje najprv všetky predné strany a potom všetky zadné.",
+ "howItWorksStep2Title": "Nastavte bod rozdelenia",
+ "howItWorksStep2": "Označte, kde končí blok predných strán, a vyberte, či sa má blok zadných strán obrátiť.",
+ "howItWorksStep3Title": "Usporiadajte a stiahnite",
+ "howItWorksStep3": "Získate jedno PDF s prestriedanými stranami alebo ZIP so samostatným súborom pre každý pôvodný dokument.",
+ "faqOddPagesQuestion": "Čo ak má môj sken nepárny počet strán?",
+ "faqOddPagesAnswer": "Obojstranné skenovanie by malo mať párny počet strán. Ak ho nemá, niektorá strana sa pravdepodobne naskenovala dvakrát alebo sa vynechala. Nástroj vás pred spracovaním upozorní a strany bez páru sa pridajú na koniec, takže sa nič nestratí.",
+ "faqBackOrderQuestion": "Prečo by som mal ponechať blok zadných strán bez zmeny?",
+ "faqBackOrderAnswer": "Závisí to od toho, ako bol stoh vložený pri druhom prechode. Otočenie celého stohu poskytne zadné strany v obrátenom poradí, čo je predvolené nastavenie. Ak ich váš skener vrátil v rovnakom poradí ako predné strany, zvoľte ponechanie bloku bez zmeny."
+ },
"addAttachments": {
"name": "Pridať prílohy",
"subtitle": "Vložiť jeden alebo viac súborov do vášho PDF."
diff --git a/public/locales/sv/common.json b/public/locales/sv/common.json
index 9a960b84..0836863f 100644
--- a/public/locales/sv/common.json
+++ b/public/locales/sv/common.json
@@ -216,7 +216,7 @@
},
"analytics": {
"question": "Använder ni cookies eller analys för att spåra mig?",
- "answer": "Vi bryr oss om din integritet. BentoPDF spårar inte personlig information. Vi använder Simple Analytics enbart för att se anonyma besöksantal. Det betyder att vi kan se hur många användare som besöker vår site, men vi vet aldrig vem du är. Simple Analytics är fullt GDPR-kompatibelt och respekterar din integritet."
+ "answer": "Nej. BentoPDF spårar dig inte alls. Vi använder inga cookies, ingen analys och inga spårningsskript – inte ens på den publika webbplatsen bentopdf.com. Vi vet aldrig vem du är eller vad du gör med verktygen."
}
},
"testimonials": {
diff --git a/public/locales/sv/tools.json b/public/locales/sv/tools.json
index c8aee989..5eba4cdf 100644
--- a/public/locales/sv/tools.json
+++ b/public/locales/sv/tools.json
@@ -55,6 +55,122 @@
"name": "PDF-redigerare",
"subtitle": "Kommentera, markera, redigera, kommentera, lägg till former/bilder, sök och visa PDF:er."
},
+ "editPdfText": {
+ "name": "Redigera PDF-text",
+ "subtitle": "Klicka på valfritt stycke och redigera det på plats med direkt textomflöde, typsnitt och stilar.",
+ "saved": "PDF-filen sparades! ({{size}} KB)",
+ "failedToLoad": "Det gick inte att läsa in PDF-textredigeraren.",
+ "howToUpload": "Ladda upp PDF",
+ "howToClick": "Klicka på ett stycke",
+ "howToRestyle": "Ge allt ny stil",
+ "relatedPdfEditor": "Kommentera och markera PDF-filer",
+ "relatedSignPdf": "Rita, skriv eller ladda upp en signatur",
+ "relatedWatermark": "Stämpla text eller bilder",
+ "relatedStamps": "Placera stämplar på sidor",
+ "relatedCompress": "Minska filstorleken",
+ "relatedOrganizeName": "Organisera PDF",
+ "relatedOrganize": "Ordna om och hantera sidor",
+ "open": "Öppna",
+ "export": "Exportera",
+ "filterAll": "Alla",
+ "filterText": "Text",
+ "filterImages": "Bilder",
+ "filterShapes": "Former",
+ "addText": "Lägg till text",
+ "langAuto": "Automatisk",
+ "change": "Ändra",
+ "ignore": "Ignorera",
+ "next": "Nästa",
+ "replace": "Ersätt",
+ "replaceAll": "Ersätt alla",
+ "emptyState": "Öppna en PDF för att redigera text, bilder och former.",
+ "choosePdf": "Välj en PDF…",
+ "font": "Teckensnitt",
+ "systemFonts": "Systemteckensnitt",
+ "color": "Färg",
+ "alignment": "Justering",
+ "style": "Stil",
+ "lists": "Listor",
+ "outline": "Kontur",
+ "spacing": "Avstånd",
+ "direction": "Riktning",
+ "dirAuto": "Automatisk",
+ "pathStroke": "Bankontur",
+ "tipBackToUpload": "Tillbaka till uppladdning",
+ "tipOpenAnotherPdf": "Öppna en annan PDF",
+ "tipExportS": "Exportera (⌘S)",
+ "tipUndoZ": "Ångra (⌘Z)",
+ "tipRedoZ": "Gör om (⇧⌘Z)",
+ "tipFileDetails": "Filinformation",
+ "tipEditExistingContent": "Redigera befintligt innehåll",
+ "tipWhatTheEditToolTouchesNarrowTheScopeOnBus": "Vad verktyget Redigera påverkar – begränsa omfattningen på fulla sidor",
+ "tipClickThePageToAddATextBox": "Klicka på sidan för att lägga till en textruta",
+ "tipSpellCheckLanguage": "Språk för stavningskontroll",
+ "tipFindAndReplace": "Sök och ersätt",
+ "tipAddAnImage": "Lägg till en bild",
+ "tipCopyThisPageSTextInReadingOrder": "Kopiera sidans text i läsordning",
+ "tipCheckTheSpellingOfThisPage": "Kontrollera stavningen på den här sidan",
+ "tipShowOrHideTheFormatPanel": "Visa eller dölj formatpanelen",
+ "phReplaceWith": "Ersätt med",
+ "tipAddToYourDictionary": "Lägg till i ordlistan",
+ "tipClose": "Stäng",
+ "phFind": "Sök",
+ "tipPreviousMatch": "Föregående träff",
+ "tipNextMatch": "Nästa träff",
+ "tipRotateLeft90": "Rotera 90° åt vänster",
+ "tipRotateRight90": "Rotera 90° åt höger",
+ "tipFlipHorizontal": "Vänd vågrätt",
+ "tipFlipVertical": "Vänd lodrätt",
+ "tipReplaceThisImageKeepingItsPlacement": "Ersätt den här bilden och behåll placeringen",
+ "tipAlternateTextForAccessibilityTaggedPdfs": "Alternativtext för tillgänglighet (taggade PDF-filer)",
+ "tipBringToFront": "Flytta längst fram",
+ "tipSendToBack": "Flytta längst bak",
+ "tipDuplicate": "Duplicera",
+ "tipDelete": "Ta bort",
+ "tipAlignLeftEdges": "Justera vänsterkanter",
+ "tipAlignHorizontalCenters": "Centrera vågrätt",
+ "tipAlignRightEdges": "Justera högerkanter",
+ "tipAlignTops": "Justera överkanter",
+ "tipAlignVerticalCenters": "Centrera lodrätt",
+ "tipAlignBottoms": "Justera underkanter",
+ "tipDistributeHorizontally": "Fördela vågrätt",
+ "tipDistributeVertically": "Fördela lodrätt",
+ "tipFontSize": "Teckenstorlek",
+ "tipListFontsInstalledOnThisDeviceChromium": "Lista teckensnitt som är installerade på den här enheten (Chromium)",
+ "tipCustomTextColor": "Anpassad textfärg",
+ "tipAlignLeft": "Vänsterjustera",
+ "tipAlignCenter": "Centrera",
+ "tipAlignRight": "Högerjustera",
+ "tipJustify": "Marginaljustera",
+ "tipBold": "Fet",
+ "tipItalic": "Kursiv",
+ "tipUnderline": "Understruken",
+ "tipStrikethrough": "Genomstruken",
+ "tipSuperscript": "Upphöjd",
+ "tipSubscript": "Nedsänkt",
+ "tipToggleBulletedItem": "Växla punktlista",
+ "tipToggleNumberedItem": "Växla numrerad punkt",
+ "tipNumberedListMarkerStyle": "Markörstil för numrerade listor",
+ "tipDecreaseListIndentTab": "Minska indrag (⇧Tab)",
+ "tipIncreaseListIndentTab": "Öka indrag (Tab)",
+ "tipTextOutlineColor": "Färg på textkontur",
+ "tipOutlineWidth0None": "Konturbredd (0 = ingen)",
+ "tipFromContent": "Från innehåll",
+ "tipLeftToRight": "Vänster till höger",
+ "tipRightToLeft": "Höger till vänster",
+ "tipStrokeColor": "Linjefärg",
+ "tipStrokeWidth": "Linjebredd",
+ "tipZoomPresets": "Zoomförinställningar",
+ "tipZoomOut": "Zooma ut",
+ "tipZoomIn": "Zooma in",
+ "tipFitPage": "Anpassa sida",
+ "tipPreviousPage": "Föregående sida",
+ "tipTypeAPageNumberAndPressEnter": "Skriv ett sidnummer och tryck på Retur",
+ "tipNextPage": "Nästa sida",
+ "tipDocumentProperties": "Dokumentegenskaper",
+ "tipHideBar": "Dölj fältet",
+ "tipShowBar": "Visa fältet"
+ },
"jpgToPdf": {
"name": "JPG till PDF",
"subtitle": "Skapa en PDF från JPG, JPEG och JPEG2000 (JP2/JPX)-bilder."
@@ -514,6 +630,51 @@
"retainPageLabels": "Behåll de ursprungliga sidetiketterna från varje fil",
"retainPageLabelsHint": "Av (standard): den sammanslagna PDF:en använder naturlig sidnumrering 1–N. På: varje fils ursprungliga sidetiketter bevaras (kan ge duplicerade etiketter)."
},
+ "duplexCollate": {
+ "name": "Sortera dubbelsidig skanning",
+ "subtitle": "Ordna om en PDF som skannats i två block (först alla framsidor, sedan alla baksidor) till rätt fram-/baksidesordning.",
+ "totalPagesLabel": "Totalt antal sidor:",
+ "splitAfterPageLabel": "Dela efter sida",
+ "splitAfterPagePlaceholder": "t.ex. 50",
+ "backBlockOrderLabel": "Ordning för baksidesblocket",
+ "backBlockReverseOption": "Vänd baksidesblocket (vanligt när bunten vänds)",
+ "backBlockKeepOption": "Behåll baksidesblocket som det är",
+ "autoSplitButton": "Hitta delning automatiskt (hälften)",
+ "exportGroupedLabel": "Exportera grupperade filer efter originaldokumentets längd",
+ "pagesPerDocumentLabel": "Sidor per originaldokument",
+ "groupedHelp": "Om detta är aktiverat blir resultatet en ZIP-fil med en PDF per grupperat dokument.",
+ "collateButton": "Sortera PDF",
+ "invalidFileTitle": "Ogiltig fil",
+ "invalidFileMessage": "Välj en PDF-fil.",
+ "loadingPdf": "Läser in PDF...",
+ "loadErrorMessage": "Det gick inte att läsa in PDF-filen.",
+ "fileMeta": "{{size}} - {{count}} sidor",
+ "oddPageWarning": "Udda antal sidor ({{count}}). En dubbelsidig skanning bör ha ett jämnt antal sidor; en sida kan saknas eller vara för mycket.",
+ "previewSummary": "Framsidesblock: {{frontCount}} sidor, baksidesblock: {{backCount}} sidor. Resultat: {{outputCount}} sidor i fram-/baksidesordning.",
+ "previewMismatchWarning": "Fram- och baksidesblocken är olika långa ({{frontCount}} framsidor mot {{backCount}} baksidor). De {{extraCount}} överblivna sidorna i det längre blocket läggs till oparade i slutet.",
+ "missingFileTitle": "Fil saknas",
+ "missingFileMessage": "Ladda upp en PDF med minst 2 sidor innan du bearbetar den.",
+ "invalidSplitTitle": "Ogiltig delningspunkt",
+ "invalidSplitMessage": "Delningspunkten måste ligga mellan sida 1 och sida {{maxSplit}}.",
+ "unevenConfirmTitle": "Ojämna block",
+ "unevenConfirmMessage": "Framsidesblocket har {{frontCount}} sidor och baksidesblocket {{backCount}} sidor.\nDet betyder oftast att en sida skannats två gånger eller saknas.\n\nDe {{extraCount}} oparade sidorna läggs till i slutet.\n\nVill du fortsätta ändå?",
+ "invalidGroupTitle": "Ogiltig gruppstorlek",
+ "invalidGroupMessage": "Ange ett giltigt antal sidor per originaldokument.",
+ "collatingLoader": "Sorterar dubbelsidig skanning...",
+ "successMessage": "Den sorterade PDF-filen har skapats.",
+ "successGroupedMessage": "Sorteringen är klar. {{fileCount}} grupperade filer har skapats.",
+ "processErrorMessage": "Det gick inte att sortera PDF-filen.",
+ "howItWorksStep1Title": "Ladda upp skanningen",
+ "howItWorksStep1": "Släpp den enda PDF-filen som innehåller alla framsidor följt av alla baksidor.",
+ "howItWorksStep2Title": "Ange delningspunkten",
+ "howItWorksStep2": "Markera var framsidesblocket slutar och välj om baksidesblocket behöver vändas.",
+ "howItWorksStep3Title": "Sortera och ladda ner",
+ "howItWorksStep3": "Du får en PDF med varvade sidor, eller en ZIP-fil med en separat fil per originaldokument.",
+ "faqOddPagesQuestion": "Vad händer om min skanning har ett udda antal sidor?",
+ "faqOddPagesAnswer": "En dubbelsidig skanning bör ha ett jämnt antal sidor. Om den inte har det har en sida troligen skannats två gånger eller hoppats över. Verktyget varnar dig före bearbetningen, och oparade sidor läggs till i slutet så att inget går förlorat.",
+ "faqBackOrderQuestion": "Varför skulle jag behålla baksidesblocket som det är?",
+ "faqBackOrderAnswer": "Det beror på hur bunten matades in vid den andra körningen. Att vända hela bunten ger baksidor i omvänd ordning, vilket är standardinställningen. Om din skanner returnerade dem i samma ordning som framsidorna väljer du att behålla blocket som det är."
+ },
"addAttachments": {
"name": "Lägg till bilagor",
"subtitle": "Bädda in en eller flera filer i din PDF."
diff --git a/public/locales/tr/common.json b/public/locales/tr/common.json
index 9c9b4ffb..ae3436b5 100644
--- a/public/locales/tr/common.json
+++ b/public/locales/tr/common.json
@@ -216,7 +216,7 @@
},
"analytics": {
"question": "Beni takip etmek için çerez veya analiz kullanıyor musunuz?",
- "answer": "Gizliliğinizi önemsiyoruz. BentoPDF kişisel bilgileri takip etmez. Sadece anonim ziyaretçi sayılarını görmek için Simple Analytics kullanıyoruz. Bu, sitemizi kaç kişinin ziyaret ettiğini görebileceğimiz, ancak kim olduğunuzu asla bilemeyeceğimiz anlamına gelir. Simple Analytics tamamen GDPR uyumludur ve gizliliğinize saygı gösterir."
+ "answer": "Hayır. BentoPDF sizi hiçbir şekilde takip etmez. Çerez, analiz aracı veya izleme betiği kullanmıyoruz; canlı web sitesi bentopdf.com'da bile. Kim olduğunuzu veya araçlarla ne yaptığınızı asla bilmeyiz."
}
},
"testimonials": {
diff --git a/public/locales/tr/tools.json b/public/locales/tr/tools.json
index 57cc750d..dbb229ac 100644
--- a/public/locales/tr/tools.json
+++ b/public/locales/tr/tools.json
@@ -55,6 +55,122 @@
"name": "PDF Düzenleyici",
"subtitle": "Açıklama ekleyin, vurgulayın, düzenleyin, yorum yapın, şekil/resim ekleyin, arama yapın ve PDF'leri görüntüleyin."
},
+ "editPdfText": {
+ "name": "PDF Metni Düzenle",
+ "subtitle": "Herhangi bir paragrafa tıklayın ve canlı yeniden akış, yazı tipleri ve stillerle yerinde düzenleyin.",
+ "saved": "PDF başarıyla kaydedildi! ({{size}} KB)",
+ "failedToLoad": "PDF Metin Düzenleyici yüklenemedi.",
+ "howToUpload": "PDF yükle",
+ "howToClick": "Bir paragrafa tıklayın",
+ "howToRestyle": "Her şeyi yeniden biçimlendirin",
+ "relatedPdfEditor": "PDF'lere not ekleyin ve işaretleyin",
+ "relatedSignPdf": "İmza çizin, yazın veya yükleyin",
+ "relatedWatermark": "Metin veya görsel damgalayın",
+ "relatedStamps": "Sayfalara damga yerleştirin",
+ "relatedCompress": "Dosya boyutunu küçültün",
+ "relatedOrganizeName": "PDF düzenle",
+ "relatedOrganize": "Sayfaları yeniden sıralayın ve yönetin",
+ "open": "Aç",
+ "export": "Dışa aktar",
+ "filterAll": "Tümü",
+ "filterText": "Metin",
+ "filterImages": "Görseller",
+ "filterShapes": "Şekiller",
+ "addText": "Metin ekle",
+ "langAuto": "Otomatik",
+ "change": "Değiştir",
+ "ignore": "Yoksay",
+ "next": "İleri",
+ "replace": "Değiştir",
+ "replaceAll": "Tümünü değiştir",
+ "emptyState": "Metnini, görsellerini ve şekillerini düzenlemek için bir PDF açın.",
+ "choosePdf": "Bir PDF seçin…",
+ "font": "Yazı tipi",
+ "systemFonts": "Sistem yazı tipleri",
+ "color": "Renk",
+ "alignment": "Hizalama",
+ "style": "Stil",
+ "lists": "Listeler",
+ "outline": "Dış çizgi",
+ "spacing": "Aralık",
+ "direction": "Yön",
+ "dirAuto": "Otomatik",
+ "pathStroke": "Yol konturu",
+ "tipBackToUpload": "Yüklemeye dön",
+ "tipOpenAnotherPdf": "Başka bir PDF aç",
+ "tipExportS": "Dışa aktar (⌘S)",
+ "tipUndoZ": "Geri al (⌘Z)",
+ "tipRedoZ": "Yinele (⇧⌘Z)",
+ "tipFileDetails": "Dosya ayrıntıları",
+ "tipEditExistingContent": "Mevcut içeriği düzenle",
+ "tipWhatTheEditToolTouchesNarrowTheScopeOnBus": "Düzenle aracının etkilediği öğeler — yoğun sayfalarda kapsamı daraltın",
+ "tipClickThePageToAddATextBox": "Metin kutusu eklemek için sayfaya tıklayın",
+ "tipSpellCheckLanguage": "Yazım denetimi dili",
+ "tipFindAndReplace": "Bul ve değiştir",
+ "tipAddAnImage": "Görsel ekle",
+ "tipCopyThisPageSTextInReadingOrder": "Bu sayfanın metnini okuma sırasına göre kopyala",
+ "tipCheckTheSpellingOfThisPage": "Bu sayfanın yazımını denetle",
+ "tipShowOrHideTheFormatPanel": "Biçim panelini göster veya gizle",
+ "phReplaceWith": "Şununla değiştir",
+ "tipAddToYourDictionary": "Sözlüğe ekle",
+ "tipClose": "Kapat",
+ "phFind": "Bul",
+ "tipPreviousMatch": "Önceki eşleşme",
+ "tipNextMatch": "Sonraki eşleşme",
+ "tipRotateLeft90": "90° sola döndür",
+ "tipRotateRight90": "90° sağa döndür",
+ "tipFlipHorizontal": "Yatay çevir",
+ "tipFlipVertical": "Dikey çevir",
+ "tipReplaceThisImageKeepingItsPlacement": "Bu görseli, yerleşimini koruyarak değiştir",
+ "tipAlternateTextForAccessibilityTaggedPdfs": "Erişilebilirlik için alternatif metin (etiketli PDF'ler)",
+ "tipBringToFront": "Öne getir",
+ "tipSendToBack": "Arkaya gönder",
+ "tipDuplicate": "Çoğalt",
+ "tipDelete": "Sil",
+ "tipAlignLeftEdges": "Sol kenarları hizala",
+ "tipAlignHorizontalCenters": "Yatayda ortala",
+ "tipAlignRightEdges": "Sağ kenarları hizala",
+ "tipAlignTops": "Üst kenarları hizala",
+ "tipAlignVerticalCenters": "Dikeyde ortala",
+ "tipAlignBottoms": "Alt kenarları hizala",
+ "tipDistributeHorizontally": "Yatayda dağıt",
+ "tipDistributeVertically": "Dikeyde dağıt",
+ "tipFontSize": "Yazı tipi boyutu",
+ "tipListFontsInstalledOnThisDeviceChromium": "Bu cihazda yüklü yazı tiplerini listele (Chromium)",
+ "tipCustomTextColor": "Özel metin rengi",
+ "tipAlignLeft": "Sola hizala",
+ "tipAlignCenter": "Ortala",
+ "tipAlignRight": "Sağa hizala",
+ "tipJustify": "İki yana yasla",
+ "tipBold": "Kalın",
+ "tipItalic": "İtalik",
+ "tipUnderline": "Altı çizili",
+ "tipStrikethrough": "Üstü çizili",
+ "tipSuperscript": "Üst simge",
+ "tipSubscript": "Alt simge",
+ "tipToggleBulletedItem": "Madde işaretini aç/kapat",
+ "tipToggleNumberedItem": "Numaralı öğeyi aç/kapat",
+ "tipNumberedListMarkerStyle": "Numaralı liste işareti stili",
+ "tipDecreaseListIndentTab": "Girintiyi azalt (⇧Tab)",
+ "tipIncreaseListIndentTab": "Girintiyi artır (Tab)",
+ "tipTextOutlineColor": "Metin dış çizgi rengi",
+ "tipOutlineWidth0None": "Dış çizgi kalınlığı (0 = yok)",
+ "tipFromContent": "İçerikten",
+ "tipLeftToRight": "Soldan sağa",
+ "tipRightToLeft": "Sağdan sola",
+ "tipStrokeColor": "Kontur rengi",
+ "tipStrokeWidth": "Kontur kalınlığı",
+ "tipZoomPresets": "Yakınlaştırma ön ayarları",
+ "tipZoomOut": "Uzaklaştır",
+ "tipZoomIn": "Yakınlaştır",
+ "tipFitPage": "Sayfaya sığdır",
+ "tipPreviousPage": "Önceki sayfa",
+ "tipTypeAPageNumberAndPressEnter": "Sayfa numarası yazıp Enter'a basın",
+ "tipNextPage": "Sonraki sayfa",
+ "tipDocumentProperties": "Belge özellikleri",
+ "tipHideBar": "Çubuğu gizle",
+ "tipShowBar": "Çubuğu göster"
+ },
"jpgToPdf": {
"name": "JPG'den PDF'ye",
"subtitle": "Bir veya daha fazla JPG görselinden PDF oluşturun."
@@ -514,6 +630,51 @@
"retainPageLabels": "Her dosyanın orijinal sayfa etiketlerini koru",
"retainPageLabelsHint": "Kapalı (varsayılan): birleştirilmiş PDF doğal 1–N sayfa numaralandırmasını kullanır. Açık: her dosyanın orijinal sayfa etiketleri korunur (yinelenen etiketler oluşabilir)."
},
+ "duplexCollate": {
+ "name": "Çift taraflı taramayı harmanla",
+ "subtitle": "İki blok hâlinde taranmış bir PDF'i (önce tüm ön yüzler, sonra tüm arka yüzler) doğru ön/arka sırasına göre yeniden düzenleyin.",
+ "totalPagesLabel": "Toplam sayfa:",
+ "splitAfterPageLabel": "Şu sayfadan sonra böl",
+ "splitAfterPagePlaceholder": "örn. 50",
+ "backBlockOrderLabel": "Arka yüz bloğu sırası",
+ "backBlockReverseOption": "Arka yüz bloğunu ters çevir (deste ters konduğunda yaygındır)",
+ "backBlockKeepOption": "Arka yüz bloğunu olduğu gibi bırak",
+ "autoSplitButton": "Bölme noktasını otomatik bul (yarısı)",
+ "exportGroupedLabel": "Dosyaları özgün belge uzunluğuna göre gruplayarak dışa aktar",
+ "pagesPerDocumentLabel": "Özgün belge başına sayfa",
+ "groupedHelp": "Etkinleştirilirse çıktı, her gruplanmış belge için bir PDF içeren bir ZIP dosyası olur.",
+ "collateButton": "PDF'i harmanla",
+ "invalidFileTitle": "Geçersiz dosya",
+ "invalidFileMessage": "Lütfen bir PDF dosyası seçin.",
+ "loadingPdf": "PDF yükleniyor...",
+ "loadErrorMessage": "PDF dosyası yüklenemedi.",
+ "fileMeta": "{{size}} - {{count}} sayfa",
+ "oddPageWarning": "Sayfa sayısı tek ({{count}}). Çift taraflı taramanın sayfa sayısı çift olmalıdır; eksik ya da fazla bir sayfa olabilir.",
+ "previewSummary": "Ön yüz bloğu: {{frontCount}} sayfa, arka yüz bloğu: {{backCount}} sayfa. Çıktı: ön/arka sırasında {{outputCount}} sayfa.",
+ "previewMismatchWarning": "Ön ve arka blok uzunlukları farklı ({{frontCount}} ön, {{backCount}} arka). Uzun taraftaki {{extraCount}} fazla sayfa eşleşmeden sona eklenecek.",
+ "missingFileTitle": "Dosya eksik",
+ "missingFileMessage": "İşlemden önce en az 2 sayfalık bir PDF yükleyin.",
+ "invalidSplitTitle": "Geçersiz bölme noktası",
+ "invalidSplitMessage": "Bölme noktası 1. sayfa ile {{maxSplit}}. sayfa arasında olmalıdır.",
+ "unevenConfirmTitle": "Eşit olmayan bloklar",
+ "unevenConfirmMessage": "Ön yüz bloğunda {{frontCount}} sayfa, arka yüz bloğunda {{backCount}} sayfa var.\nBu genellikle bir sayfanın iki kez tarandığı ya da eksik olduğu anlamına gelir.\n\nEşleşmeyen {{extraCount}} sayfa sona eklenecek.\n\nYine de devam edilsin mi?",
+ "invalidGroupTitle": "Geçersiz grup boyutu",
+ "invalidGroupMessage": "Özgün belge başına geçerli bir sayfa sayısı girin.",
+ "collatingLoader": "Çift taraflı tarama harmanlanıyor...",
+ "successMessage": "Harmanlanmış PDF başarıyla oluşturuldu.",
+ "successGroupedMessage": "Harmanlama tamamlandı. {{fileCount}} gruplanmış dosya oluşturuldu.",
+ "processErrorMessage": "PDF harmanlanamadı.",
+ "howItWorksStep1Title": "Taramayı yükleyin",
+ "howItWorksStep1": "Önce tüm ön yüzleri, ardından tüm arka yüzleri içeren tek PDF'i bırakın.",
+ "howItWorksStep2Title": "Bölme noktasını belirleyin",
+ "howItWorksStep2": "Ön yüz bloğunun nerede bittiğini işaretleyin, sonra arka yüz bloğunun ters çevrilmesi gerekip gerekmediğini seçin.",
+ "howItWorksStep3Title": "Harmanlayın ve indirin",
+ "howItWorksStep3": "Sayfaları sırayla yerleştirilmiş tek bir PDF ya da her özgün belge için ayrı dosya içeren bir ZIP alın.",
+ "faqOddPagesQuestion": "Taramamda tek sayıda sayfa varsa ne olur?",
+ "faqOddPagesAnswer": "Çift taraflı taramanın sayfa sayısı çift olmalıdır. Değilse, bir sayfa muhtemelen iki kez tarandı ya da atlandı. Araç işlemden önce sizi uyarır ve eşleşmeyen sayfalar sona eklenir; böylece hiçbir şey kaybolmaz.",
+ "faqBackOrderQuestion": "Arka yüz bloğunu neden olduğu gibi bırakayım?",
+ "faqBackOrderAnswer": "Bu, ikinci geçişte destenin nasıl yerleştirildiğine bağlıdır. Tüm desteyi ters çevirmek arka yüzleri ters sırada verir; varsayılan ayar budur. Tarayıcınız bunları ön yüzlerle aynı sırada verdiyse bloğu olduğu gibi bırakmayı seçin."
+ },
"addAttachments": {
"name": "Ek Dosya Ekle",
"subtitle": "PDF'nize bir veya daha fazla dosya ekleyin."
diff --git a/public/locales/uk/common.json b/public/locales/uk/common.json
index 12d31796..7f90b251 100644
--- a/public/locales/uk/common.json
+++ b/public/locales/uk/common.json
@@ -216,7 +216,7 @@
},
"analytics": {
"question": "Чи використовуєте ви cookies або аналітику?",
- "answer": "Ми поважаємо вашу приватність. BentoPDF не відстежує персональні дані. Ми використовуємо Simple Analytics лише для анонімної статистики відвідувань. Ми бачимо кількість користувачів, але не знаємо, хто саме ви. Simple Analytics відповідає GDPR і поважає вашу приватність."
+ "answer": "Ні. BentoPDF вас ніяк не відстежує. Ми не використовуємо файли cookie, аналітику чи скрипти відстеження — навіть на робочому сайті bentopdf.com. Ми ніколи не знаємо, хто ви і що ви робите з інструментами."
}
},
"testimonials": {
diff --git a/public/locales/uk/tools.json b/public/locales/uk/tools.json
index 420d83d3..a1c470b9 100644
--- a/public/locales/uk/tools.json
+++ b/public/locales/uk/tools.json
@@ -55,6 +55,122 @@
"name": "Редактор PDF",
"subtitle": "Додавати анотації, виділяти, редагувати, коментувати, додавати фігури/зображення, шукати та переглядати PDF."
},
+ "editPdfText": {
+ "name": "Редагувати текст PDF",
+ "subtitle": "Натисніть на будь-який абзац і редагуйте його на місці з миттєвим перекомпонуванням тексту, шрифтами та стилями.",
+ "saved": "PDF успішно збережено! ({{size}} КБ)",
+ "failedToLoad": "Не вдалося завантажити редактор тексту PDF.",
+ "howToUpload": "Завантажити PDF",
+ "howToClick": "Натисніть на абзац",
+ "howToRestyle": "Змініть стиль",
+ "relatedPdfEditor": "Коментуйте та розмічайте PDF",
+ "relatedSignPdf": "Намалюйте, введіть або завантажте підпис",
+ "relatedWatermark": "Нанесіть текст або зображення",
+ "relatedStamps": "Розміщуйте штампи на сторінках",
+ "relatedCompress": "Зменште розмір файлу",
+ "relatedOrganizeName": "Упорядкувати PDF",
+ "relatedOrganize": "Змінюйте порядок і керуйте сторінками",
+ "open": "Відкрити",
+ "export": "Експорт",
+ "filterAll": "Усі",
+ "filterText": "Текст",
+ "filterImages": "Зображення",
+ "filterShapes": "Фігури",
+ "addText": "Додати текст",
+ "langAuto": "Авто",
+ "change": "Змінити",
+ "ignore": "Пропустити",
+ "next": "Далі",
+ "replace": "Замінити",
+ "replaceAll": "Замінити все",
+ "emptyState": "Відкрийте PDF, щоб редагувати текст, зображення та фігури.",
+ "choosePdf": "Виберіть PDF…",
+ "font": "Шрифт",
+ "systemFonts": "Системні шрифти",
+ "color": "Колір",
+ "alignment": "Вирівнювання",
+ "style": "Стиль",
+ "lists": "Списки",
+ "outline": "Обведення",
+ "spacing": "Інтервал",
+ "direction": "Напрямок",
+ "dirAuto": "Авто",
+ "pathStroke": "Обведення контуру",
+ "tipBackToUpload": "Назад до завантаження",
+ "tipOpenAnotherPdf": "Відкрити інший PDF",
+ "tipExportS": "Експорт (⌘S)",
+ "tipUndoZ": "Скасувати (⌘Z)",
+ "tipRedoZ": "Повторити (⇧⌘Z)",
+ "tipFileDetails": "Відомості про файл",
+ "tipEditExistingContent": "Редагувати наявний вміст",
+ "tipWhatTheEditToolTouchesNarrowTheScopeOnBus": "На що діє інструмент редагування — звузьте область на насичених сторінках",
+ "tipClickThePageToAddATextBox": "Натисніть на сторінку, щоб додати текстове поле",
+ "tipSpellCheckLanguage": "Мова перевірки орфографії",
+ "tipFindAndReplace": "Знайти й замінити",
+ "tipAddAnImage": "Додати зображення",
+ "tipCopyThisPageSTextInReadingOrder": "Скопіювати текст сторінки в порядку читання",
+ "tipCheckTheSpellingOfThisPage": "Перевірити орфографію на цій сторінці",
+ "tipShowOrHideTheFormatPanel": "Показати або сховати панель форматування",
+ "phReplaceWith": "Замінити на",
+ "tipAddToYourDictionary": "Додати до словника",
+ "tipClose": "Закрити",
+ "phFind": "Знайти",
+ "tipPreviousMatch": "Попередній збіг",
+ "tipNextMatch": "Наступний збіг",
+ "tipRotateLeft90": "Повернути на 90° ліворуч",
+ "tipRotateRight90": "Повернути на 90° праворуч",
+ "tipFlipHorizontal": "Відобразити горизонтально",
+ "tipFlipVertical": "Відобразити вертикально",
+ "tipReplaceThisImageKeepingItsPlacement": "Замінити зображення, зберігши розташування",
+ "tipAlternateTextForAccessibilityTaggedPdfs": "Альтернативний текст для доступності (теговані PDF)",
+ "tipBringToFront": "На передній план",
+ "tipSendToBack": "На задній план",
+ "tipDuplicate": "Дублювати",
+ "tipDelete": "Видалити",
+ "tipAlignLeftEdges": "Вирівняти за лівими краями",
+ "tipAlignHorizontalCenters": "Центрувати горизонтально",
+ "tipAlignRightEdges": "Вирівняти за правими краями",
+ "tipAlignTops": "Вирівняти за верхніми краями",
+ "tipAlignVerticalCenters": "Центрувати вертикально",
+ "tipAlignBottoms": "Вирівняти за нижніми краями",
+ "tipDistributeHorizontally": "Розподілити горизонтально",
+ "tipDistributeVertically": "Розподілити вертикально",
+ "tipFontSize": "Розмір шрифту",
+ "tipListFontsInstalledOnThisDeviceChromium": "Показати шрифти, встановлені на цьому пристрої (Chromium)",
+ "tipCustomTextColor": "Власний колір тексту",
+ "tipAlignLeft": "За лівим краєм",
+ "tipAlignCenter": "По центру",
+ "tipAlignRight": "За правим краєм",
+ "tipJustify": "За шириною",
+ "tipBold": "Жирний",
+ "tipItalic": "Курсив",
+ "tipUnderline": "Підкреслений",
+ "tipStrikethrough": "Закреслений",
+ "tipSuperscript": "Верхній індекс",
+ "tipSubscript": "Нижній індекс",
+ "tipToggleBulletedItem": "Маркований пункт",
+ "tipToggleNumberedItem": "Нумерований пункт",
+ "tipNumberedListMarkerStyle": "Стиль маркера нумерованих списків",
+ "tipDecreaseListIndentTab": "Зменшити відступ (⇧Tab)",
+ "tipIncreaseListIndentTab": "Збільшити відступ (Tab)",
+ "tipTextOutlineColor": "Колір контуру тексту",
+ "tipOutlineWidth0None": "Товщина контуру (0 = немає)",
+ "tipFromContent": "Із вмісту",
+ "tipLeftToRight": "Зліва направо",
+ "tipRightToLeft": "Справа наліво",
+ "tipStrokeColor": "Колір обведення",
+ "tipStrokeWidth": "Товщина обведення",
+ "tipZoomPresets": "Попередні налаштування масштабу",
+ "tipZoomOut": "Зменшити",
+ "tipZoomIn": "Збільшити",
+ "tipFitPage": "За розміром сторінки",
+ "tipPreviousPage": "Попередня сторінка",
+ "tipTypeAPageNumberAndPressEnter": "Введіть номер сторінки та натисніть Enter",
+ "tipNextPage": "Наступна сторінка",
+ "tipDocumentProperties": "Властивості документа",
+ "tipHideBar": "Сховати панель",
+ "tipShowBar": "Показати панель"
+ },
"jpgToPdf": {
"name": "JPG у PDF",
"subtitle": "Створити PDF із зображень JPG, JPEG та JPEG2000 (JP2/JPX)."
@@ -514,6 +630,51 @@
"retainPageLabels": "Зберігати оригінальні мітки сторінок з кожного файлу",
"retainPageLabelsHint": "Вимкнено (за замовчуванням): об’єднаний PDF використовує природну нумерацію 1–N. Увімкнено: оригінальні мітки сторінок кожного файлу зберігаються (можуть з’явитися дублікати)."
},
+ "duplexCollate": {
+ "name": "Сортування двостороннього сканування",
+ "subtitle": "Упорядковує PDF, відсканований двома блоками (спочатку всі лицьові сторони, потім усі звороти), у правильний порядок лицьова/зворот.",
+ "totalPagesLabel": "Усього сторінок:",
+ "splitAfterPageLabel": "Розділити після сторінки",
+ "splitAfterPagePlaceholder": "напр. 50",
+ "backBlockOrderLabel": "Порядок блоку зворотів",
+ "backBlockReverseOption": "Обернути блок зворотів (типово для перевернутої стопки)",
+ "backBlockKeepOption": "Залишити блок зворотів без змін",
+ "autoSplitButton": "Визначити розділення автоматично (половина)",
+ "exportGroupedLabel": "Експортувати файли групами за довжиною вихідного документа",
+ "pagesPerDocumentLabel": "Сторінок у вихідному документі",
+ "groupedHelp": "Якщо ввімкнено, результатом буде ZIP-архів з окремим PDF для кожного згрупованого документа.",
+ "collateButton": "Зібрати PDF",
+ "invalidFileTitle": "Недійсний файл",
+ "invalidFileMessage": "Виберіть файл PDF.",
+ "loadingPdf": "Завантаження PDF...",
+ "loadErrorMessage": "Не вдалося завантажити файл PDF.",
+ "fileMeta": "{{size}} - {{count}} стор.",
+ "oddPageWarning": "Непарна кількість сторінок ({{count}}). Двостороннє сканування має мати парну кількість сторінок; можливо, сторінки бракує або є зайва.",
+ "previewSummary": "Блок лицьових сторін: {{frontCount}} стор., блок зворотів: {{backCount}} стор. Результат: {{outputCount}} стор. у порядку лицьова/зворот.",
+ "previewMismatchWarning": "Довжина блоків лицьових і зворотних сторін різна ({{frontCount}} лицьових проти {{backCount}} зворотів). Зайві сторінки ({{extraCount}}) з довшого блоку буде додано без пари в кінець.",
+ "missingFileTitle": "Файл не вибрано",
+ "missingFileMessage": "Перед обробкою завантажте PDF щонайменше з 2 сторінками.",
+ "invalidSplitTitle": "Недійсна точка розділення",
+ "invalidSplitMessage": "Точка розділення має бути між сторінкою 1 і сторінкою {{maxSplit}}.",
+ "unevenConfirmTitle": "Нерівні блоки",
+ "unevenConfirmMessage": "У блоці лицьових сторін {{frontCount}} стор., у блоці зворотів — {{backCount}} стор.\nЗазвичай це означає, що сторінку відскановано двічі або її бракує.\n\nСторінки без пари ({{extraCount}}) буде додано в кінець.\n\nВсе одно продовжити?",
+ "invalidGroupTitle": "Недійсний розмір групи",
+ "invalidGroupMessage": "Введіть коректну кількість сторінок у вихідному документі.",
+ "collatingLoader": "Збирання двостороннього сканування...",
+ "successMessage": "Зібраний PDF успішно створено.",
+ "successGroupedMessage": "Збирання завершено. Створено файлів: {{fileCount}}.",
+ "processErrorMessage": "Не вдалося зібрати PDF.",
+ "howItWorksStep1Title": "Завантажте скан",
+ "howItWorksStep1": "Перетягніть один PDF, у якому спочатку йдуть усі лицьові сторони, а потім усі звороти.",
+ "howItWorksStep2Title": "Задайте точку розділення",
+ "howItWorksStep2": "Укажіть, де закінчується блок лицьових сторін, і виберіть, чи потрібно обертати блок зворотів.",
+ "howItWorksStep3Title": "Зберіть і завантажте",
+ "howItWorksStep3": "Ви отримаєте один PDF із чергуванням сторінок або ZIP-архів з окремим файлом для кожного вихідного документа.",
+ "faqOddPagesQuestion": "Що робити, якщо у скані непарна кількість сторінок?",
+ "faqOddPagesAnswer": "Двостороннє сканування має мати парну кількість сторінок. Якщо це не так, сторінку, найімовірніше, відскановано двічі або пропущено. Інструмент попередить вас перед обробкою, а сторінки без пари буде додано в кінець, щоб нічого не загубилося.",
+ "faqBackOrderQuestion": "Навіщо залишати блок зворотів без змін?",
+ "faqBackOrderAnswer": "Це залежить від того, як стопку подавали на другому проході. Якщо перевернути всю стопку, звороти йдуть у зворотному порядку — це варіант за замовчуванням. Якщо сканер видав їх у тому самому порядку, що й лицьові сторони, виберіть залишити блок без змін."
+ },
"addAttachments": {
"name": "Додати вкладення",
"subtitle": "Вбудувати один або кілька файлів у PDF."
diff --git a/public/locales/vi/common.json b/public/locales/vi/common.json
index 04ddc0d4..f392afff 100644
--- a/public/locales/vi/common.json
+++ b/public/locales/vi/common.json
@@ -216,7 +216,7 @@
},
"analytics": {
"question": "Bạn có sử dụng cookie hoặc phân tích để theo dõi tôi không?",
- "answer": "Chúng tôi quan tâm đến quyền riêng tư của bạn. BentoPDF không theo dõi thông tin cá nhân. Chúng tôi chỉ sử dụng Simple Analytics để xem số lượt truy cập ẩn danh. Điều này có nghĩa là chúng tôi có thể biết có bao nhiêu người dùng truy cập trang web của chúng tôi, nhưng chúng tôi không bao giờ biết bạn là ai. Simple Analytics hoàn toàn tuân thủ GDPR và tôn trọng quyền riêng tư của bạn."
+ "answer": "Không. BentoPDF hoàn toàn không theo dõi bạn. Chúng tôi không dùng cookie, công cụ phân tích hay bất kỳ tập lệnh theo dõi nào, kể cả trên trang web chính thức bentopdf.com. Chúng tôi không bao giờ biết bạn là ai hay bạn làm gì với các công cụ."
}
},
"testimonials": {
diff --git a/public/locales/vi/tools.json b/public/locales/vi/tools.json
index 68b4c3db..c36b6f0e 100644
--- a/public/locales/vi/tools.json
+++ b/public/locales/vi/tools.json
@@ -55,6 +55,122 @@
"name": "Trình chỉnh sửa PDF",
"subtitle": "Ghi chú, tô sáng, chỉnh sửa, bình luận, thêm hình dạng/hình ảnh, tìm kiếm và xem PDF."
},
+ "editPdfText": {
+ "name": "Chỉnh sửa văn bản PDF",
+ "subtitle": "Nhấp vào bất kỳ đoạn văn nào và chỉnh sửa ngay tại chỗ với tính năng tự động dàn lại văn bản, phông chữ và kiểu dáng.",
+ "saved": "Đã lưu PDF thành công! ({{size}} KB)",
+ "failedToLoad": "Không thể tải Trình chỉnh sửa văn bản PDF.",
+ "howToUpload": "Tải PDF lên",
+ "howToClick": "Nhấp vào một đoạn văn",
+ "howToRestyle": "Đổi kiểu mọi thứ",
+ "relatedPdfEditor": "Chú thích và đánh dấu PDF",
+ "relatedSignPdf": "Vẽ, nhập hoặc tải lên chữ ký",
+ "relatedWatermark": "Đóng dấu văn bản hoặc hình ảnh",
+ "relatedStamps": "Đặt con dấu lên trang",
+ "relatedCompress": "Giảm kích thước tệp",
+ "relatedOrganizeName": "Sắp xếp PDF",
+ "relatedOrganize": "Sắp xếp lại và quản lý trang",
+ "open": "Mở",
+ "export": "Xuất",
+ "filterAll": "Tất cả",
+ "filterText": "Văn bản",
+ "filterImages": "Hình ảnh",
+ "filterShapes": "Hình khối",
+ "addText": "Thêm văn bản",
+ "langAuto": "Tự động",
+ "change": "Thay đổi",
+ "ignore": "Bỏ qua",
+ "next": "Tiếp theo",
+ "replace": "Thay thế",
+ "replaceAll": "Thay thế tất cả",
+ "emptyState": "Mở một PDF để chỉnh sửa văn bản, hình ảnh và hình khối.",
+ "choosePdf": "Chọn một PDF…",
+ "font": "Phông chữ",
+ "systemFonts": "Phông chữ hệ thống",
+ "color": "Màu",
+ "alignment": "Căn chỉnh",
+ "style": "Kiểu",
+ "lists": "Danh sách",
+ "outline": "Đường viền",
+ "spacing": "Giãn cách",
+ "direction": "Hướng",
+ "dirAuto": "Tự động",
+ "pathStroke": "Nét đường dẫn",
+ "tipBackToUpload": "Quay lại tải lên",
+ "tipOpenAnotherPdf": "Mở PDF khác",
+ "tipExportS": "Xuất (⌘S)",
+ "tipUndoZ": "Hoàn tác (⌘Z)",
+ "tipRedoZ": "Làm lại (⇧⌘Z)",
+ "tipFileDetails": "Chi tiết tệp",
+ "tipEditExistingContent": "Chỉnh sửa nội dung hiện có",
+ "tipWhatTheEditToolTouchesNarrowTheScopeOnBus": "Phạm vi tác động của công cụ Chỉnh sửa — thu hẹp phạm vi trên trang dày đặc",
+ "tipClickThePageToAddATextBox": "Nhấp vào trang để thêm hộp văn bản",
+ "tipSpellCheckLanguage": "Ngôn ngữ kiểm tra chính tả",
+ "tipFindAndReplace": "Tìm và thay thế",
+ "tipAddAnImage": "Thêm hình ảnh",
+ "tipCopyThisPageSTextInReadingOrder": "Sao chép văn bản trang này theo thứ tự đọc",
+ "tipCheckTheSpellingOfThisPage": "Kiểm tra chính tả trang này",
+ "tipShowOrHideTheFormatPanel": "Hiện hoặc ẩn bảng định dạng",
+ "phReplaceWith": "Thay bằng",
+ "tipAddToYourDictionary": "Thêm vào từ điển",
+ "tipClose": "Đóng",
+ "phFind": "Tìm",
+ "tipPreviousMatch": "Kết quả trước",
+ "tipNextMatch": "Kết quả tiếp theo",
+ "tipRotateLeft90": "Xoay trái 90°",
+ "tipRotateRight90": "Xoay phải 90°",
+ "tipFlipHorizontal": "Lật ngang",
+ "tipFlipVertical": "Lật dọc",
+ "tipReplaceThisImageKeepingItsPlacement": "Thay ảnh này và giữ nguyên vị trí",
+ "tipAlternateTextForAccessibilityTaggedPdfs": "Văn bản thay thế cho trợ năng (PDF có thẻ)",
+ "tipBringToFront": "Đưa lên trước",
+ "tipSendToBack": "Đưa ra sau",
+ "tipDuplicate": "Nhân bản",
+ "tipDelete": "Xóa",
+ "tipAlignLeftEdges": "Căn mép trái",
+ "tipAlignHorizontalCenters": "Căn giữa theo chiều ngang",
+ "tipAlignRightEdges": "Căn mép phải",
+ "tipAlignTops": "Căn mép trên",
+ "tipAlignVerticalCenters": "Căn giữa theo chiều dọc",
+ "tipAlignBottoms": "Căn mép dưới",
+ "tipDistributeHorizontally": "Phân bố theo chiều ngang",
+ "tipDistributeVertically": "Phân bố theo chiều dọc",
+ "tipFontSize": "Cỡ chữ",
+ "tipListFontsInstalledOnThisDeviceChromium": "Liệt kê phông chữ đã cài trên thiết bị này (Chromium)",
+ "tipCustomTextColor": "Màu chữ tùy chỉnh",
+ "tipAlignLeft": "Căn trái",
+ "tipAlignCenter": "Căn giữa",
+ "tipAlignRight": "Căn phải",
+ "tipJustify": "Căn đều",
+ "tipBold": "Đậm",
+ "tipItalic": "Nghiêng",
+ "tipUnderline": "Gạch chân",
+ "tipStrikethrough": "Gạch ngang",
+ "tipSuperscript": "Chỉ số trên",
+ "tipSubscript": "Chỉ số dưới",
+ "tipToggleBulletedItem": "Bật/tắt mục dấu đầu dòng",
+ "tipToggleNumberedItem": "Bật/tắt mục đánh số",
+ "tipNumberedListMarkerStyle": "Kiểu ký hiệu danh sách đánh số",
+ "tipDecreaseListIndentTab": "Giảm thụt lề (⇧Tab)",
+ "tipIncreaseListIndentTab": "Tăng thụt lề (Tab)",
+ "tipTextOutlineColor": "Màu viền chữ",
+ "tipOutlineWidth0None": "Độ dày viền (0 = không có)",
+ "tipFromContent": "Từ nội dung",
+ "tipLeftToRight": "Trái sang phải",
+ "tipRightToLeft": "Phải sang trái",
+ "tipStrokeColor": "Màu nét",
+ "tipStrokeWidth": "Độ dày nét",
+ "tipZoomPresets": "Mức thu phóng đặt sẵn",
+ "tipZoomOut": "Thu nhỏ",
+ "tipZoomIn": "Phóng to",
+ "tipFitPage": "Vừa trang",
+ "tipPreviousPage": "Trang trước",
+ "tipTypeAPageNumberAndPressEnter": "Nhập số trang rồi nhấn Enter",
+ "tipNextPage": "Trang sau",
+ "tipDocumentProperties": "Thuộc tính tài liệu",
+ "tipHideBar": "Ẩn thanh",
+ "tipShowBar": "Hiện thanh"
+ },
"jpgToPdf": {
"name": "JPG sang PDF",
"subtitle": "Tạo PDF từ hình ảnh JPG, JPEG và JPEG2000 (JP2/JPX)."
@@ -514,6 +630,51 @@
"retainPageLabels": "Giữ nhãn trang gốc từ mỗi tệp",
"retainPageLabelsHint": "Tắt (mặc định): PDF đã hợp nhất sử dụng đánh số trang tự nhiên 1–N. Bật: nhãn trang gốc của từng tệp được giữ lại (có thể tạo ra nhãn trùng lặp)."
},
+ "duplexCollate": {
+ "name": "Sắp xếp bản quét hai mặt",
+ "subtitle": "Sắp xếp lại tệp PDF được quét thành hai khối (tất cả mặt trước, rồi tất cả mặt sau) thành đúng thứ tự trước/sau.",
+ "totalPagesLabel": "Tổng số trang:",
+ "splitAfterPageLabel": "Tách sau trang",
+ "splitAfterPagePlaceholder": "vd. 50",
+ "backBlockOrderLabel": "Thứ tự khối mặt sau",
+ "backBlockReverseOption": "Đảo ngược khối mặt sau (thường gặp khi lật chồng giấy)",
+ "backBlockKeepOption": "Giữ nguyên khối mặt sau",
+ "autoSplitButton": "Tự động xác định điểm tách (một nửa)",
+ "exportGroupedLabel": "Xuất tệp theo nhóm dựa trên độ dài tài liệu gốc",
+ "pagesPerDocumentLabel": "Số trang mỗi tài liệu gốc",
+ "groupedHelp": "Nếu bật, kết quả là tệp ZIP chứa một PDF cho mỗi tài liệu được nhóm.",
+ "collateButton": "Sắp xếp PDF",
+ "invalidFileTitle": "Tệp không hợp lệ",
+ "invalidFileMessage": "Vui lòng chọn một tệp PDF.",
+ "loadingPdf": "Đang tải PDF...",
+ "loadErrorMessage": "Không tải được tệp PDF.",
+ "fileMeta": "{{size}} - {{count}} trang",
+ "oddPageWarning": "Số trang lẻ ({{count}}). Bản quét hai mặt phải có số trang chẵn; có thể thiếu hoặc thừa một trang.",
+ "previewSummary": "Khối mặt trước: {{frontCount}} trang, khối mặt sau: {{backCount}} trang. Kết quả: {{outputCount}} trang, theo thứ tự trước/sau.",
+ "previewMismatchWarning": "Độ dài khối mặt trước và mặt sau khác nhau ({{frontCount}} mặt trước so với {{backCount}} mặt sau). {{extraCount}} trang dư ở khối dài hơn sẽ được thêm vào cuối mà không có trang ghép đôi.",
+ "missingFileTitle": "Thiếu tệp",
+ "missingFileMessage": "Vui lòng tải lên PDF có ít nhất 2 trang trước khi xử lý.",
+ "invalidSplitTitle": "Điểm tách không hợp lệ",
+ "invalidSplitMessage": "Điểm tách phải nằm giữa trang 1 và trang {{maxSplit}}.",
+ "unevenConfirmTitle": "Khối không đều",
+ "unevenConfirmMessage": "Khối mặt trước có {{frontCount}} trang và khối mặt sau có {{backCount}} trang.\nĐiều này thường có nghĩa là một trang bị quét hai lần hoặc bị thiếu.\n\n{{extraCount}} trang không ghép đôi sẽ được thêm vào cuối.\n\nVẫn tiếp tục?",
+ "invalidGroupTitle": "Kích thước nhóm không hợp lệ",
+ "invalidGroupMessage": "Vui lòng nhập số trang hợp lệ cho mỗi tài liệu gốc.",
+ "collatingLoader": "Đang sắp xếp bản quét hai mặt...",
+ "successMessage": "Đã tạo PDF đã sắp xếp thành công.",
+ "successGroupedMessage": "Sắp xếp hoàn tất. Đã tạo {{fileCount}} tệp nhóm.",
+ "processErrorMessage": "Không sắp xếp được PDF.",
+ "howItWorksStep1Title": "Tải bản quét lên",
+ "howItWorksStep1": "Thả vào tệp PDF duy nhất chứa tất cả mặt trước rồi đến tất cả mặt sau.",
+ "howItWorksStep2Title": "Đặt điểm tách",
+ "howItWorksStep2": "Đánh dấu nơi khối mặt trước kết thúc, sau đó chọn xem khối mặt sau có cần đảo ngược hay không.",
+ "howItWorksStep3Title": "Sắp xếp và tải xuống",
+ "howItWorksStep3": "Nhận một tệp PDF có các trang xen kẽ, hoặc một tệp ZIP với từng tệp riêng cho mỗi tài liệu gốc.",
+ "faqOddPagesQuestion": "Nếu bản quét của tôi có số trang lẻ thì sao?",
+ "faqOddPagesAnswer": "Bản quét hai mặt phải có số trang chẵn. Nếu không, có thể một trang đã bị quét hai lần hoặc bị bỏ sót. Công cụ sẽ cảnh báo bạn trước khi xử lý, và những trang không ghép đôi sẽ được thêm vào cuối để không mất gì.",
+ "faqBackOrderQuestion": "Tại sao tôi lại giữ nguyên khối mặt sau?",
+ "faqBackOrderAnswer": "Điều đó tùy thuộc vào cách xếp chồng giấy ở lần quét thứ hai. Lật cả chồng giấy sẽ cho các mặt sau theo thứ tự ngược lại, và đó là tùy chọn mặc định. Nếu máy quét trả về chúng theo cùng thứ tự với mặt trước, hãy chọn giữ nguyên khối."
+ },
"addAttachments": {
"name": "Thêm tệp đính kèm",
"subtitle": "Nhúng một hoặc nhiều tệp vào PDF của bạn."
diff --git a/public/locales/zh-TW/common.json b/public/locales/zh-TW/common.json
index f785effc..c6ec1d32 100644
--- a/public/locales/zh-TW/common.json
+++ b/public/locales/zh-TW/common.json
@@ -216,7 +216,7 @@
},
"analytics": {
"question": "BentoPDF 會使用 Cookie 或網站分析工具追蹤我嗎?",
- "answer": "我們非常重視您的隱私。BentoPDF 不會追蹤任何個人資料。我們僅使用 Simple Analytics 來統計匿名造訪次數。這代表我們只會知道有多少使用者造訪過我們的網站,但永遠不會知道您是誰。Simple Analytics 完全符合 GDPR 規範並尊重您的隱私權。"
+ "answer": "不會。BentoPDF 完全不會追蹤您。我們不使用 Cookie、分析工具或任何追蹤指令碼,即使在正式網站 bentopdf.com 上也是如此。我們永遠不知道您是誰,也不知道您用這些工具做了什麼。"
}
},
"testimonials": {
diff --git a/public/locales/zh-TW/tools.json b/public/locales/zh-TW/tools.json
index 28681e60..bcf27f0c 100644
--- a/public/locales/zh-TW/tools.json
+++ b/public/locales/zh-TW/tools.json
@@ -55,6 +55,122 @@
"name": "PDF 編輯器",
"subtitle": "註解、螢光標註、塗黑、評論、新增圖形或圖片、搜尋與檢視 PDF。"
},
+ "editPdfText": {
+ "name": "編輯 PDF 文字",
+ "subtitle": "點擊任意段落即可就地編輯,支援即時重排、字型與樣式設定。",
+ "saved": "PDF 儲存成功!({{size}} KB)",
+ "failedToLoad": "無法載入 PDF 文字編輯器。",
+ "howToUpload": "上傳 PDF",
+ "howToClick": "點擊段落",
+ "howToRestyle": "隨意變更樣式",
+ "relatedPdfEditor": "為 PDF 加上註解與標記",
+ "relatedSignPdf": "手寫、輸入或上傳簽名",
+ "relatedWatermark": "疊加文字或圖片",
+ "relatedStamps": "在頁面上加入印章",
+ "relatedCompress": "縮小檔案大小",
+ "relatedOrganizeName": "整理 PDF",
+ "relatedOrganize": "重新排序與管理頁面",
+ "open": "開啟",
+ "export": "匯出",
+ "filterAll": "全部",
+ "filterText": "文字",
+ "filterImages": "圖片",
+ "filterShapes": "形狀",
+ "addText": "加入文字",
+ "langAuto": "自動",
+ "change": "變更",
+ "ignore": "忽略",
+ "next": "下一個",
+ "replace": "取代",
+ "replaceAll": "全部取代",
+ "emptyState": "開啟 PDF 即可編輯其中的文字、圖片與形狀。",
+ "choosePdf": "選擇 PDF…",
+ "font": "字型",
+ "systemFonts": "系統字型",
+ "color": "顏色",
+ "alignment": "對齊",
+ "style": "樣式",
+ "lists": "清單",
+ "outline": "外框",
+ "spacing": "間距",
+ "direction": "方向",
+ "dirAuto": "自動",
+ "pathStroke": "路徑筆畫",
+ "tipBackToUpload": "返回上傳",
+ "tipOpenAnotherPdf": "開啟其他 PDF",
+ "tipExportS": "匯出 (⌘S)",
+ "tipUndoZ": "復原 (⌘Z)",
+ "tipRedoZ": "重做 (⇧⌘Z)",
+ "tipFileDetails": "檔案詳細資料",
+ "tipEditExistingContent": "編輯現有內容",
+ "tipWhatTheEditToolTouchesNarrowTheScopeOnBus": "編輯工具的作用範圍——在內容密集的頁面上縮小範圍",
+ "tipClickThePageToAddATextBox": "點擊頁面以加入文字方塊",
+ "tipSpellCheckLanguage": "拼字檢查語言",
+ "tipFindAndReplace": "尋找與取代",
+ "tipAddAnImage": "加入圖片",
+ "tipCopyThisPageSTextInReadingOrder": "依閱讀順序複製本頁文字",
+ "tipCheckTheSpellingOfThisPage": "檢查本頁拼字",
+ "tipShowOrHideTheFormatPanel": "顯示或隱藏格式面板",
+ "phReplaceWith": "取代為",
+ "tipAddToYourDictionary": "加入字典",
+ "tipClose": "關閉",
+ "phFind": "尋找",
+ "tipPreviousMatch": "上一個相符項",
+ "tipNextMatch": "下一個相符項",
+ "tipRotateLeft90": "向左旋轉 90°",
+ "tipRotateRight90": "向右旋轉 90°",
+ "tipFlipHorizontal": "水平翻轉",
+ "tipFlipVertical": "垂直翻轉",
+ "tipReplaceThisImageKeepingItsPlacement": "取代此圖片並保留其位置",
+ "tipAlternateTextForAccessibilityTaggedPdfs": "無障礙替代文字(含標記的 PDF)",
+ "tipBringToFront": "移至最上層",
+ "tipSendToBack": "移至最下層",
+ "tipDuplicate": "複製",
+ "tipDelete": "刪除",
+ "tipAlignLeftEdges": "靠左邊緣對齊",
+ "tipAlignHorizontalCenters": "水平置中對齊",
+ "tipAlignRightEdges": "靠右邊緣對齊",
+ "tipAlignTops": "靠上對齊",
+ "tipAlignVerticalCenters": "垂直置中對齊",
+ "tipAlignBottoms": "靠下對齊",
+ "tipDistributeHorizontally": "水平均分",
+ "tipDistributeVertically": "垂直均分",
+ "tipFontSize": "字型大小",
+ "tipListFontsInstalledOnThisDeviceChromium": "列出此裝置已安裝的字型(Chromium)",
+ "tipCustomTextColor": "自訂文字顏色",
+ "tipAlignLeft": "靠左對齊",
+ "tipAlignCenter": "置中對齊",
+ "tipAlignRight": "靠右對齊",
+ "tipJustify": "左右對齊",
+ "tipBold": "粗體",
+ "tipItalic": "斜體",
+ "tipUnderline": "底線",
+ "tipStrikethrough": "刪除線",
+ "tipSuperscript": "上標",
+ "tipSubscript": "下標",
+ "tipToggleBulletedItem": "切換項目符號",
+ "tipToggleNumberedItem": "切換編號項目",
+ "tipNumberedListMarkerStyle": "編號清單標記樣式",
+ "tipDecreaseListIndentTab": "減少縮排 (⇧Tab)",
+ "tipIncreaseListIndentTab": "增加縮排 (Tab)",
+ "tipTextOutlineColor": "文字外框顏色",
+ "tipOutlineWidth0None": "外框寬度(0 = 無)",
+ "tipFromContent": "來自內容",
+ "tipLeftToRight": "由左至右",
+ "tipRightToLeft": "由右至左",
+ "tipStrokeColor": "筆畫顏色",
+ "tipStrokeWidth": "筆畫寬度",
+ "tipZoomPresets": "縮放預設值",
+ "tipZoomOut": "縮小",
+ "tipZoomIn": "放大",
+ "tipFitPage": "符合頁面",
+ "tipPreviousPage": "上一頁",
+ "tipTypeAPageNumberAndPressEnter": "輸入頁碼後按 Enter",
+ "tipNextPage": "下一頁",
+ "tipDocumentProperties": "文件屬性",
+ "tipHideBar": "隱藏工具列",
+ "tipShowBar": "顯示工具列"
+ },
"jpgToPdf": {
"name": "JPG 轉 PDF",
"subtitle": "從一張或多張 JPG、JPEG 及 JPEG2000(JP2/JPX)圖片建立 PDF。"
@@ -514,6 +630,51 @@
"retainPageLabels": "保留每個檔案的原始頁面標籤",
"retainPageLabelsHint": "關閉(預設):合併後的 PDF 使用自然頁碼 1–N。開啟:保留每個檔案的原始頁面標籤(可能產生重複的標籤)。"
},
+ "duplexCollate": {
+ "name": "雙面掃描整理",
+ "subtitle": "將分兩個區塊掃描的 PDF(先全部正面,再全部背面)重新排列成正確的正面/背面順序。",
+ "totalPagesLabel": "總頁數:",
+ "splitAfterPageLabel": "在此頁後分割",
+ "splitAfterPagePlaceholder": "例如 50",
+ "backBlockOrderLabel": "背面區塊順序",
+ "backBlockReverseOption": "反轉背面區塊(紙疊翻面掃描時常見)",
+ "backBlockKeepOption": "保持背面區塊原樣",
+ "autoSplitButton": "自動偵測分割點(對半)",
+ "exportGroupedLabel": "依原始文件長度分組匯出檔案",
+ "pagesPerDocumentLabel": "每份原始文件的頁數",
+ "groupedHelp": "啟用後,輸出為 ZIP 檔,其中每份分組文件對應一個 PDF。",
+ "collateButton": "整理 PDF",
+ "invalidFileTitle": "檔案無效",
+ "invalidFileMessage": "請選擇 PDF 檔案。",
+ "loadingPdf": "正在載入 PDF...",
+ "loadErrorMessage": "無法載入 PDF 檔案。",
+ "fileMeta": "{{size}} - {{count}} 頁",
+ "oddPageWarning": "頁數為奇數({{count}} 頁)。雙面掃描的頁數應為偶數,可能缺少或多出一頁。",
+ "previewSummary": "正面區塊:{{frontCount}} 頁,背面區塊:{{backCount}} 頁。輸出:{{outputCount}} 頁,依正面/背面順序排列。",
+ "previewMismatchWarning": "正面與背面區塊頁數不一致(正面 {{frontCount}} 頁,背面 {{backCount}} 頁)。較長一側多出的 {{extraCount}} 頁將不配對地附加在結尾。",
+ "missingFileTitle": "缺少檔案",
+ "missingFileMessage": "請先上傳至少 2 頁的 PDF 再進行處理。",
+ "invalidSplitTitle": "分割點無效",
+ "invalidSplitMessage": "分割點必須介於第 1 頁與第 {{maxSplit}} 頁之間。",
+ "unevenConfirmTitle": "區塊不均等",
+ "unevenConfirmMessage": "正面區塊有 {{frontCount}} 頁,背面區塊有 {{backCount}} 頁。\n這通常表示某一頁被掃描兩次或缺失。\n\n未配對的 {{extraCount}} 頁將附加在結尾。\n\n仍要繼續嗎?",
+ "invalidGroupTitle": "分組大小無效",
+ "invalidGroupMessage": "請輸入有效的每份原始文件頁數。",
+ "collatingLoader": "正在整理雙面掃描...",
+ "successMessage": "已成功產生整理後的 PDF。",
+ "successGroupedMessage": "整理完成,已產生 {{fileCount}} 個分組檔案。",
+ "processErrorMessage": "無法整理 PDF。",
+ "howItWorksStep1Title": "上傳掃描檔",
+ "howItWorksStep1": "拖入一個 PDF,其中先是全部正面,接著是全部背面。",
+ "howItWorksStep2Title": "設定分割點",
+ "howItWorksStep2": "標出正面區塊結束的位置,然後選擇背面區塊是否需要反轉。",
+ "howItWorksStep3Title": "整理並下載",
+ "howItWorksStep3": "取得一個正反交錯排列的 PDF,或一個為每份原始文件各自成檔的 ZIP。",
+ "faqOddPagesQuestion": "如果掃描檔的頁數是奇數怎麼辦?",
+ "faqOddPagesAnswer": "雙面掃描的頁數應為偶數。若不是,通常表示某一頁被掃描兩次或被漏掉。處理前工具會提醒你,未配對的頁面會附加在結尾,因此不會遺失任何內容。",
+ "faqBackOrderQuestion": "什麼情況下要保持背面區塊原樣?",
+ "faqBackOrderAnswer": "這取決於第二次掃描時紙疊是如何放入的。將整疊紙翻面會讓背面呈倒序,這也是預設設定。如果掃描器輸出的背面順序與正面相同,請選擇保持背面區塊原樣。"
+ },
"addAttachments": {
"name": "新增附件",
"subtitle": "嵌入一個或多個檔案至您的 PDF 中。"
diff --git a/public/locales/zh/common.json b/public/locales/zh/common.json
index f8c225d0..267ab092 100644
--- a/public/locales/zh/common.json
+++ b/public/locales/zh/common.json
@@ -216,7 +216,7 @@
},
"analytics": {
"question": "你们使用 Cookie 或分析来跟踪我吗?",
- "answer": "我们关心您的隐私。BentoPDF 不会跟踪个人信息。我们仅使用 Simple Analytics 查看匿名访问计数。这意味着我们可以知道有多少用户访问我们的网站,但我们永远不知道您是谁。Simple Analytics 完全符合 GDPR 并尊重您的隐私。"
+ "answer": "不会。BentoPDF 完全不会追踪您。我们不使用 Cookie、分析工具或任何追踪脚本,即使在正式网站 bentopdf.com 上也是如此。我们永远不知道您是谁,也不知道您用这些工具做了什么。"
}
},
"testimonials": {
diff --git a/public/locales/zh/tools.json b/public/locales/zh/tools.json
index ee75b27d..b155385f 100644
--- a/public/locales/zh/tools.json
+++ b/public/locales/zh/tools.json
@@ -55,6 +55,122 @@
"name": "PDF 编辑器",
"subtitle": "注释、高亮、涂黑、评论、添加形状/图片、搜索和查看 PDF。"
},
+ "editPdfText": {
+ "name": "编辑 PDF 文本",
+ "subtitle": "点击任意段落即可就地编辑,支持实时重排、字体和样式设置。",
+ "saved": "PDF 保存成功!({{size}} KB)",
+ "failedToLoad": "无法加载 PDF 文本编辑器。",
+ "howToUpload": "上传 PDF",
+ "howToClick": "点击段落",
+ "howToRestyle": "随意restyle",
+ "relatedPdfEditor": "为 PDF 添加批注和标记",
+ "relatedSignPdf": "手写、输入或上传签名",
+ "relatedWatermark": "叠加文字或图片",
+ "relatedStamps": "在页面上添加图章",
+ "relatedCompress": "减小文件体积",
+ "relatedOrganizeName": "整理 PDF",
+ "relatedOrganize": "重新排序和管理页面",
+ "open": "打开",
+ "export": "导出",
+ "filterAll": "全部",
+ "filterText": "文本",
+ "filterImages": "图片",
+ "filterShapes": "形状",
+ "addText": "添加文本",
+ "langAuto": "自动",
+ "change": "更改",
+ "ignore": "忽略",
+ "next": "下一个",
+ "replace": "替换",
+ "replaceAll": "全部替换",
+ "emptyState": "打开一个 PDF 即可编辑其中的文本、图片和形状。",
+ "choosePdf": "选择 PDF…",
+ "font": "字体",
+ "systemFonts": "系统字体",
+ "color": "颜色",
+ "alignment": "对齐",
+ "style": "样式",
+ "lists": "列表",
+ "outline": "轮廓",
+ "spacing": "间距",
+ "direction": "方向",
+ "dirAuto": "自动",
+ "pathStroke": "路径描边",
+ "tipBackToUpload": "返回上传",
+ "tipOpenAnotherPdf": "打开另一个 PDF",
+ "tipExportS": "导出 (⌘S)",
+ "tipUndoZ": "撤销 (⌘Z)",
+ "tipRedoZ": "重做 (⇧⌘Z)",
+ "tipFileDetails": "文件详情",
+ "tipEditExistingContent": "编辑现有内容",
+ "tipWhatTheEditToolTouchesNarrowTheScopeOnBus": "编辑工具的作用范围——在内容密集的页面上缩小范围",
+ "tipClickThePageToAddATextBox": "点击页面以添加文本框",
+ "tipSpellCheckLanguage": "拼写检查语言",
+ "tipFindAndReplace": "查找和替换",
+ "tipAddAnImage": "添加图片",
+ "tipCopyThisPageSTextInReadingOrder": "按阅读顺序复制本页文本",
+ "tipCheckTheSpellingOfThisPage": "检查本页拼写",
+ "tipShowOrHideTheFormatPanel": "显示或隐藏格式面板",
+ "phReplaceWith": "替换为",
+ "tipAddToYourDictionary": "添加到词典",
+ "tipClose": "关闭",
+ "phFind": "查找",
+ "tipPreviousMatch": "上一个匹配项",
+ "tipNextMatch": "下一个匹配项",
+ "tipRotateLeft90": "向左旋转 90°",
+ "tipRotateRight90": "向右旋转 90°",
+ "tipFlipHorizontal": "水平翻转",
+ "tipFlipVertical": "垂直翻转",
+ "tipReplaceThisImageKeepingItsPlacement": "替换此图片并保持其位置",
+ "tipAlternateTextForAccessibilityTaggedPdfs": "无障碍替代文本(带标记的 PDF)",
+ "tipBringToFront": "置于顶层",
+ "tipSendToBack": "置于底层",
+ "tipDuplicate": "复制",
+ "tipDelete": "删除",
+ "tipAlignLeftEdges": "左边缘对齐",
+ "tipAlignHorizontalCenters": "水平居中对齐",
+ "tipAlignRightEdges": "右边缘对齐",
+ "tipAlignTops": "顶端对齐",
+ "tipAlignVerticalCenters": "垂直居中对齐",
+ "tipAlignBottoms": "底端对齐",
+ "tipDistributeHorizontally": "水平分布",
+ "tipDistributeVertically": "垂直分布",
+ "tipFontSize": "字号",
+ "tipListFontsInstalledOnThisDeviceChromium": "列出本设备已安装的字体(Chromium)",
+ "tipCustomTextColor": "自定义文字颜色",
+ "tipAlignLeft": "左对齐",
+ "tipAlignCenter": "居中对齐",
+ "tipAlignRight": "右对齐",
+ "tipJustify": "两端对齐",
+ "tipBold": "加粗",
+ "tipItalic": "斜体",
+ "tipUnderline": "下划线",
+ "tipStrikethrough": "删除线",
+ "tipSuperscript": "上标",
+ "tipSubscript": "下标",
+ "tipToggleBulletedItem": "切换项目符号",
+ "tipToggleNumberedItem": "切换编号项",
+ "tipNumberedListMarkerStyle": "编号列表标记样式",
+ "tipDecreaseListIndentTab": "减少缩进 (⇧Tab)",
+ "tipIncreaseListIndentTab": "增加缩进 (Tab)",
+ "tipTextOutlineColor": "文字轮廓颜色",
+ "tipOutlineWidth0None": "轮廓宽度(0 = 无)",
+ "tipFromContent": "来自内容",
+ "tipLeftToRight": "从左到右",
+ "tipRightToLeft": "从右到左",
+ "tipStrokeColor": "描边颜色",
+ "tipStrokeWidth": "描边宽度",
+ "tipZoomPresets": "缩放预设",
+ "tipZoomOut": "缩小",
+ "tipZoomIn": "放大",
+ "tipFitPage": "适合页面",
+ "tipPreviousPage": "上一页",
+ "tipTypeAPageNumberAndPressEnter": "输入页码后按 Enter",
+ "tipNextPage": "下一页",
+ "tipDocumentProperties": "文档属性",
+ "tipHideBar": "隐藏工具栏",
+ "tipShowBar": "显示工具栏"
+ },
"jpgToPdf": {
"name": "JPG 转 PDF",
"subtitle": "从 JPG、JPEG 和 JPEG2000 (JP2/JPX) 图片创建 PDF。"
@@ -514,6 +630,51 @@
"retainPageLabels": "保留每个文件的原始页面标签",
"retainPageLabelsHint": "关闭(默认):合并后的 PDF 使用自然页码 1–N。开启:保留每个文件的原始页面标签(可能产生重复标签)。"
},
+ "duplexCollate": {
+ "name": "双面扫描整理",
+ "subtitle": "将分两块扫描的 PDF(先全部正面,再全部背面)重新排列为正确的正面/背面顺序。",
+ "totalPagesLabel": "总页数:",
+ "splitAfterPageLabel": "在此页后分割",
+ "splitAfterPagePlaceholder": "例如 50",
+ "backBlockOrderLabel": "背面块顺序",
+ "backBlockReverseOption": "反转背面块(纸叠翻面扫描时常用)",
+ "backBlockKeepOption": "保持背面块原样",
+ "autoSplitButton": "自动检测分割点(对半)",
+ "exportGroupedLabel": "按原始文档长度分组导出文件",
+ "pagesPerDocumentLabel": "每份原始文档的页数",
+ "groupedHelp": "启用后,输出为 ZIP 文件,其中每份分组文档对应一个 PDF。",
+ "collateButton": "整理 PDF",
+ "invalidFileTitle": "文件无效",
+ "invalidFileMessage": "请选择 PDF 文件。",
+ "loadingPdf": "正在加载 PDF...",
+ "loadErrorMessage": "无法加载 PDF 文件。",
+ "fileMeta": "{{size}} - {{count}} 页",
+ "oddPageWarning": "页数为奇数({{count}} 页)。双面扫描的页数应为偶数,可能缺少或多出一页。",
+ "previewSummary": "正面块:{{frontCount}} 页,背面块:{{backCount}} 页。输出:{{outputCount}} 页,按正面/背面顺序排列。",
+ "previewMismatchWarning": "正面块与背面块页数不一致(正面 {{frontCount}} 页,背面 {{backCount}} 页)。较长一侧多出的 {{extraCount}} 页将不配对地附加在末尾。",
+ "missingFileTitle": "缺少文件",
+ "missingFileMessage": "请先上传至少 2 页的 PDF 再进行处理。",
+ "invalidSplitTitle": "分割点无效",
+ "invalidSplitMessage": "分割点必须介于第 1 页和第 {{maxSplit}} 页之间。",
+ "unevenConfirmTitle": "页块不均等",
+ "unevenConfirmMessage": "正面块有 {{frontCount}} 页,背面块有 {{backCount}} 页。\n这通常意味着某一页被扫描了两次或缺失。\n\n未配对的 {{extraCount}} 页将附加在末尾。\n\n仍要继续吗?",
+ "invalidGroupTitle": "分组大小无效",
+ "invalidGroupMessage": "请输入有效的每份原始文档页数。",
+ "collatingLoader": "正在整理双面扫描...",
+ "successMessage": "已成功生成整理后的 PDF。",
+ "successGroupedMessage": "整理完成,已生成 {{fileCount}} 个分组文件。",
+ "processErrorMessage": "无法整理 PDF。",
+ "howItWorksStep1Title": "上传扫描件",
+ "howItWorksStep1": "拖入一个 PDF,其中先是全部正面,然后是全部背面。",
+ "howItWorksStep2Title": "设置分割点",
+ "howItWorksStep2": "标出正面块结束的位置,然后选择背面块是否需要反转。",
+ "howItWorksStep3Title": "整理并下载",
+ "howItWorksStep3": "得到一个正反交替排列的 PDF,或一个为每份原始文档分别成文件的 ZIP。",
+ "faqOddPagesQuestion": "如果扫描件的页数是奇数怎么办?",
+ "faqOddPagesAnswer": "双面扫描的页数应为偶数。若不是,通常说明某一页被扫描了两次或被漏掉。处理前工具会提示你,未配对的页面会附加在末尾,因此不会丢失任何内容。",
+ "faqBackOrderQuestion": "什么情况下要保持背面块原样?",
+ "faqBackOrderAnswer": "这取决于第二遍扫描时纸叠是如何放入的。将整叠纸翻面会让背面呈倒序,这也是默认设置。如果扫描仪输出的背面顺序与正面相同,请选择保持背面块原样。"
+ },
"addAttachments": {
"name": "添加附件",
"subtitle": "将一个或多个文件嵌入到您的 PDF 中。"
diff --git a/public/sw.js b/public/sw.js
index 122d756c..d36e0fd2 100644
--- a/public/sw.js
+++ b/public/sw.js
@@ -16,7 +16,14 @@ const getBasePath = () => {
return url.pathname.replace(/\/$/, '') || '';
};
-const buildCriticalAssets = () => [];
+const PRECACHE_ASSETS = [];
+
+const buildCriticalAssets = () => {
+ const basePath = getBasePath();
+ return PRECACHE_ASSETS.map(
+ (asset) => `${basePath}/${asset.replace(/^\/+/, '')}`
+ );
+};
self.addEventListener('install', (event) => {
const CRITICAL_ASSETS = buildCriticalAssets();
diff --git a/public/workers/merge.worker.js b/public/workers/merge.worker.js
index 6f536962..022441c2 100644
--- a/public/workers/merge.worker.js
+++ b/public/workers/merge.worker.js
@@ -21,7 +21,14 @@ function loadCpdf(cpdfUrl) {
}
self.onmessage = async function (e) {
- const { command, files, jobs, cpdfUrl, retainPageLabels } = e.data;
+ const {
+ command,
+ files,
+ jobs,
+ cpdfUrl,
+ retainPageLabels,
+ removeDuplicateFonts,
+ } = e.data;
if (!cpdfUrl) {
self.postMessage({
@@ -43,11 +50,16 @@ self.onmessage = async function (e) {
}
if (command === 'merge') {
- mergePDFs(files, jobs, retainPageLabels === true);
+ mergePDFs(
+ files,
+ jobs,
+ retainPageLabels === true,
+ removeDuplicateFonts === true
+ );
}
};
-function mergePDFs(files, jobs, retainPageLabels) {
+function mergePDFs(files, jobs, retainPageLabels, removeDuplicateFonts) {
try {
const loadedPdfs = {};
const pdfsToMerge = [];
@@ -90,7 +102,7 @@ function mergePDFs(files, jobs, retainPageLabels) {
const mergedPdf = coherentpdf.mergeSame(
pdfsToMerge,
retainPageLabels,
- true,
+ removeDuplicateFonts,
rangesToMerge
);
diff --git a/scripts/generate-i18n-pages.mjs b/scripts/generate-i18n-pages.mjs
index be0c2bf5..134738c2 100644
--- a/scripts/generate-i18n-pages.mjs
+++ b/scripts/generate-i18n-pages.mjs
@@ -98,6 +98,12 @@ function injectOrganizationLd(document) {
}
const BREADCRUMB_MARKER = 'data-bentopdf-breadcrumb';
+const BRAND_NAME = process.env.VITE_BRAND_NAME || 'BentoPDF';
+
+function buildLocalHomeHref(lang) {
+ const langSegment = lang === 'en' ? '/' : `/${lang}/`;
+ return `${BASE_PATH}${langSegment}`.replace(/\/+/g, '/');
+}
function injectToolBreadcrumb(document, lang, toolName, toolUrl) {
const h1 = document.querySelector('h1[data-i18n^="tools:"]');
@@ -112,9 +118,9 @@ function injectToolBreadcrumb(document, lang, toolName, toolUrl) {
nav.className = 'text-sm text-gray-400 mb-4';
const homeLink = document.createElement('a');
- homeLink.href = homeUrl;
+ homeLink.href = buildLocalHomeHref(lang);
homeLink.className = 'hover:text-indigo-300';
- homeLink.textContent = 'BentoPDF';
+ homeLink.textContent = BRAND_NAME;
const sep = document.createElement('span');
sep.setAttribute('aria-hidden', 'true');
@@ -139,7 +145,7 @@ function injectToolBreadcrumb(document, lang, toolName, toolUrl) {
{
'@type': 'ListItem',
position: 1,
- name: 'BentoPDF',
+ name: BRAND_NAME,
item: homeUrl,
},
{
diff --git a/scripts/generate-security-headers.mjs b/scripts/generate-security-headers.mjs
index f8329566..dffc184c 100644
--- a/scripts/generate-security-headers.mjs
+++ b/scripts/generate-security-headers.mjs
@@ -56,6 +56,11 @@ const connectOrigins = uniq([
]);
const fontOrigins = uniq([ocrFontOrigin].filter(Boolean));
+const githubStarsDisabled =
+ process.env.DISABLE_GITHUB_STARS === 'true' ||
+ process.env.SIMPLE_MODE === 'true';
+const githubApiSource = githubStarsDisabled ? '' : ' https://api.github.com';
+
const directives = [
`default-src 'self'`,
`script-src 'self' 'wasm-unsafe-eval' 'unsafe-eval' blob: ${scriptOrigins.join(' ')}`.trim(),
@@ -63,7 +68,7 @@ const directives = [
`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(),
+ `connect-src 'self' blob:${githubApiSource} https://fonts.gstatic.com ${connectOrigins.join(' ')}`.trim(),
`object-src 'none'`,
`base-uri 'self'`,
`frame-src 'self' blob:`,
@@ -77,7 +82,7 @@ const docsDirectives = [
`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(),
+ `connect-src 'self'${githubApiSource} https://fonts.gstatic.com ${connectOrigins.join(' ')}`.trim(),
`object-src 'none'`,
`base-uri 'self'`,
`frame-ancestors 'self'`,
diff --git a/scripts/seo-audit.mjs b/scripts/seo-audit.mjs
index e0354068..ba866cee 100644
--- a/scripts/seo-audit.mjs
+++ b/scripts/seo-audit.mjs
@@ -11,6 +11,7 @@ const SITE_URL = (process.env.SITE_URL || 'https://www.bentopdf.com').replace(
/\/+$/,
''
);
+const BASE_PATH = (process.env.BASE_URL || '/').replace(/\/$/, '');
const HOST = new URL(SITE_URL).hostname;
const NOINDEX_ALLOWLIST = new Set(['404.html', 'wasm-settings.html']);
@@ -72,6 +73,7 @@ function expectedCanonicalForFile(rel) {
const baseName = fileName.replace(/\.html$/, '');
const slug = baseName === 'index' ? '' : baseName;
const segments = [SITE_URL];
+ if (BASE_PATH) segments.push(BASE_PATH.replace(/^\//, ''));
if (slug) segments.push(slug);
return segments.join('/').replace(/\/+$/, '') || SITE_URL;
}
diff --git a/signatures/cla.json b/signatures/cla.json
index 8f11e0ac..8cf397d0 100644
--- a/signatures/cla.json
+++ b/signatures/cla.json
@@ -527,6 +527,54 @@
"created_at": "2026-07-30T15:13:48Z",
"repoId": 1074785178,
"pullRequestNo": 812
+ },
+ {
+ "name": "KourosZanbouri",
+ "id": 40575217,
+ "comment_id": 5142692442,
+ "created_at": "2026-07-31T12:08:15Z",
+ "repoId": 1074785178,
+ "pullRequestNo": 813
+ },
+ {
+ "name": "junedkhatri31",
+ "id": 20799038,
+ "comment_id": 5216703501,
+ "created_at": "2026-08-07T11:50:07Z",
+ "repoId": 1074785178,
+ "pullRequestNo": 818
+ },
+ {
+ "name": "florisporro",
+ "id": 5805436,
+ "comment_id": 5217064971,
+ "created_at": "2026-08-07T12:29:25Z",
+ "repoId": 1074785178,
+ "pullRequestNo": 803
+ },
+ {
+ "name": "mvanhorn",
+ "id": 455140,
+ "comment_id": 5309890162,
+ "created_at": "2026-08-16T22:01:38Z",
+ "repoId": 1074785178,
+ "pullRequestNo": 795
+ },
+ {
+ "name": "OctoBored",
+ "id": 212877535,
+ "comment_id": 5329340763,
+ "created_at": "2026-08-18T14:06:54Z",
+ "repoId": 1074785178,
+ "pullRequestNo": 835
+ },
+ {
+ "name": "nipunrajk",
+ "id": 65464664,
+ "comment_id": 5425563649,
+ "created_at": "2026-08-26T12:51:13Z",
+ "repoId": 1074785178,
+ "pullRequestNo": 845
}
]
}
\ No newline at end of file
diff --git a/src/css/edit-pdf-text.css b/src/css/edit-pdf-text.css
new file mode 100644
index 00000000..ff11cb22
--- /dev/null
+++ b/src/css/edit-pdf-text.css
@@ -0,0 +1,2363 @@
+#text-editor-app {
+ --ecbg: #111827;
+ --ecpanel: #1f2937;
+ --ecpanel2: #374151;
+ --ecline: #4b5563;
+ --ectext: #f3f4f6;
+ --ecmuted: #9ca3af;
+ --ecaccent: #6366f1;
+ --ecstage: #374151;
+ position: fixed;
+ inset: 0;
+ z-index: 40;
+ display: flex;
+ flex-direction: column;
+ background: var(--ecbg);
+ color: var(--ectext);
+ font:
+ 13px -apple-system,
+ BlinkMacSystemFont,
+ 'Segoe UI',
+ Roboto,
+ sans-serif;
+ overflow: hidden;
+}
+#text-editor-app[hidden] {
+ display: none;
+}
+#text-editor-app * {
+ box-sizing: border-box;
+}
+
+#text-editor-app #toolbar {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ padding: 6px 10px;
+ background: var(--ecpanel);
+ border-bottom: 1px solid var(--ecline);
+ flex: 0 0 auto;
+ flex-wrap: wrap;
+}
+#text-editor-app .btn {
+ background: var(--ecpanel2);
+ color: var(--ectext);
+ border: 1px solid var(--ecline);
+ padding: 4px 10px;
+ border-radius: 6px;
+ cursor: pointer;
+ font-size: 12px;
+ user-select: none;
+}
+#text-editor-app .btn:hover {
+ background: #4b5563;
+}
+#text-editor-app .btn:disabled {
+ opacity: 0.4;
+ cursor: default;
+}
+#text-editor-app .btn.big {
+ padding: 10px 20px;
+ font-size: 15px;
+ background: var(--ecaccent);
+ border-color: var(--ecaccent);
+}
+#text-editor-app .sep {
+ width: 1px;
+ height: 20px;
+ background: var(--ecline);
+}
+#text-editor-app .label {
+ font-variant-numeric: tabular-nums;
+ min-width: 42px;
+ text-align: center;
+ color: var(--ecmuted);
+}
+#text-editor-app .info {
+ color: var(--ecmuted);
+ font-size: 12px;
+ margin-left: auto;
+ max-width: 40vw;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
+#text-editor-app .segmented {
+ display: inline-flex;
+ border: 1px solid var(--ecline);
+ border-radius: 6px;
+ overflow: hidden;
+}
+#text-editor-app .segmented button {
+ background: var(--ecpanel2);
+ color: var(--ectext);
+ border: none;
+ border-right: 1px solid var(--ecline);
+ padding: 4px 10px;
+ cursor: pointer;
+ font-size: 12px;
+}
+#text-editor-app .segmented button:last-child {
+ border-right: none;
+}
+#text-editor-app .segmented select {
+ background: var(--ecpanel2);
+ color: var(--ectext);
+ border: none;
+ border-right: 1px solid var(--ecline);
+ padding: 0 6px;
+ font: inherit;
+ cursor: pointer;
+ outline: none;
+ -webkit-appearance: none;
+ appearance: none;
+}
+#text-editor-app .segmented select:hover {
+ background: var(--ecpanel);
+}
+#text-editor-app .segmented button.on {
+ background: var(--ecaccent);
+}
+#text-editor-app .segmented.small button {
+ padding: 3px 8px;
+}
+
+#text-editor-app #findbar[hidden],
+#text-editor-app #spellbar[hidden] {
+ display: none;
+}
+
+#text-editor-app #spellbar {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ padding: 6px 10px;
+ background: var(--ecpanel);
+ border-bottom: 1px solid var(--ecline);
+}
+#text-editor-app #spellbar input {
+ background: var(--ecpanel2);
+ color: var(--ectext);
+ border: 1px solid var(--ecline);
+ border-radius: 6px;
+ padding: 4px 8px;
+ width: 200px;
+}
+#text-editor-app #spellWord {
+ font-weight: 600;
+ min-width: 120px;
+}
+#text-editor-app .spellhit {
+ position: absolute;
+ pointer-events: none;
+ border-bottom: 2px solid #e0483d;
+ background: rgba(224, 72, 61, 0.14);
+}
+
+#text-editor-app #findbar {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ padding: 6px 10px;
+ background: var(--ecpanel);
+ border-bottom: 1px solid var(--ecline);
+}
+#text-editor-app #findbar input[type='text'],
+#text-editor-app #findbar input:not([type]) {
+ background: var(--ecpanel2);
+ color: var(--ectext);
+ border: 1px solid var(--ecline);
+ border-radius: 6px;
+ padding: 4px 8px;
+ width: 200px;
+}
+#text-editor-app .chk {
+ color: var(--ecmuted);
+ font-size: 12px;
+}
+
+#text-editor-app #main {
+ flex: 1 1 auto;
+ display: flex;
+ min-height: 0;
+}
+#text-editor-app #stage {
+ flex: 1 1 auto;
+ overflow: auto;
+ background: var(--ecstage);
+ position: relative;
+ display: flex;
+ align-items: flex-start;
+ justify-content: center;
+ padding: 24px;
+}
+#text-editor-app #empty {
+ margin: auto;
+ text-align: center;
+ color: var(--ecmuted);
+ max-width: 460px;
+}
+#text-editor-app #empty h1 {
+ color: var(--ectext);
+ font-weight: 600;
+}
+#text-editor-app #empty .hint {
+ font-size: 12px;
+ margin-top: 24px;
+}
+
+#text-editor-app #pageWrap {
+ position: relative;
+ box-shadow: 0 4px 24px rgba(0, 0, 0, 0.5);
+}
+#text-editor-app #page {
+ display: block;
+ background: #fff;
+}
+#text-editor-app #overlay {
+ position: absolute;
+ inset: 0;
+ pointer-events: none;
+}
+
+#text-editor-app .para-layer {
+ position: absolute;
+ inset: 0;
+ pointer-events: none;
+}
+#text-editor-app .para-box {
+ position: absolute;
+ border: 1px dashed rgba(156, 163, 175, 0.85);
+ pointer-events: auto;
+ cursor: text;
+}
+#text-editor-app .para-box:hover {
+ border-color: var(--ecaccent);
+ border-style: dashed;
+}
+#text-editor-app .obj-box {
+ position: absolute;
+ border: 1.5px solid var(--ecaccent);
+ pointer-events: auto;
+ cursor: move;
+}
+#text-editor-app .obj-handle {
+ position: absolute;
+ width: 10px;
+ height: 10px;
+ background: #fff;
+ border: 1.5px solid var(--ecaccent);
+ border-radius: 50%;
+ pointer-events: auto;
+ z-index: 2;
+}
+#text-editor-app .sel-para {
+ position: absolute;
+ border: 1.5px solid var(--ecaccent);
+ pointer-events: auto;
+ cursor: move;
+}
+
+#text-editor-app .editor {
+ position: absolute;
+ background: transparent;
+ pointer-events: auto;
+ overflow: hidden;
+ color: #000;
+}
+#text-editor-app .editor > [contenteditable] {
+ outline: 1px solid var(--ecaccent);
+ outline-offset: -1px;
+ width: 100%;
+ height: 100%;
+ caret-color: #000;
+}
+
+#text-editor-app .editor.pristine [contenteditable] * {
+ color: transparent !important;
+ -webkit-text-fill-color: transparent !important;
+ text-decoration-color: transparent !important;
+ -webkit-text-stroke-color: transparent !important;
+}
+#text-editor-app .editor.pristine [contenteditable] ::selection,
+#text-editor-app .editor.pristine [contenteditable]::selection {
+ background: rgba(99, 102, 241, 0.28);
+}
+
+#text-editor-app .edit-move {
+ position: absolute;
+ pointer-events: auto;
+ cursor: move;
+ z-index: 1;
+ background: transparent;
+}
+#text-editor-app .edit-move:hover {
+ background: rgba(99, 102, 241, 0.18);
+}
+
+#text-editor-app .edit-chrome,
+#text-editor-app .sel-chrome {
+ position: absolute;
+ pointer-events: none;
+}
+#text-editor-app .edit-chrome > *,
+#text-editor-app .sel-chrome > * {
+ pointer-events: auto;
+}
+
+#text-editor-app .multi-box {
+ position: absolute;
+ border: 1.5px solid var(--ecaccent);
+ background: rgba(99, 102, 241, 0.06);
+ pointer-events: auto;
+ cursor: move;
+}
+#text-editor-app .marquee {
+ position: absolute;
+ border: 1px dashed var(--ecaccent);
+ background: rgba(99, 102, 241, 0.08);
+ pointer-events: none;
+}
+
+#text-editor-app .rot-handle {
+ cursor: grab;
+}
+#text-editor-app .rot-stem {
+ position: absolute;
+ top: -14px;
+ width: 1.5px;
+ height: 14px;
+ background: var(--ecaccent);
+ pointer-events: none;
+}
+
+#text-editor-app #inspector {
+ flex: 0 0 250px;
+ background: var(--ecpanel);
+ border-left: 1px solid var(--ecline);
+ padding: 14px;
+ overflow-y: auto;
+}
+#text-editor-app #inspector h2 {
+ font-size: 13px;
+ margin: 0 0 12px;
+}
+#text-editor-app #inspector .row {
+ display: flex;
+ gap: 6px;
+ align-items: center;
+ margin-bottom: 10px;
+}
+#text-editor-app #inspector select {
+ flex: 1;
+ background: var(--ecpanel2);
+ color: var(--ectext);
+ border: 1px solid var(--ecline);
+ border-radius: 6px;
+ padding: 4px;
+}
+#text-editor-app .num {
+ width: 56px;
+ background: var(--ecpanel2);
+ color: var(--ectext);
+ border: 1px solid var(--ecline);
+ border-radius: 6px;
+ padding: 4px;
+ text-align: right;
+}
+#text-editor-app .swatches {
+ flex-wrap: wrap;
+}
+#text-editor-app .swatch {
+ width: 22px;
+ height: 22px;
+ border-radius: 11px;
+ border: 1px solid var(--ecline);
+ cursor: pointer;
+}
+#text-editor-app #fColor {
+ width: 34px;
+ height: 26px;
+ padding: 0;
+ border: 1px solid var(--ecline);
+ border-radius: 6px;
+ background: none;
+}
+#text-editor-app .toggles button {
+ width: 34px;
+ height: 28px;
+ background: var(--ecpanel2);
+ color: var(--ectext);
+ border: 1px solid var(--ecline);
+ border-radius: 6px;
+ cursor: pointer;
+}
+#text-editor-app .toggles button.on {
+ background: var(--ecaccent);
+}
+#text-editor-app #inspector hr {
+ border: none;
+ border-top: 1px solid var(--ecline);
+ margin: 12px 0;
+}
+#text-editor-app .field {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ color: var(--ecmuted);
+ font-size: 12px;
+ margin-bottom: 8px;
+}
+#text-editor-app #inspector[disabled] {
+ opacity: 0.5;
+ pointer-events: none;
+}
+
+#text-editor-app #toast {
+ position: fixed;
+ bottom: 20px;
+ left: 50%;
+ transform: translateX(-50%);
+ background: #000c;
+ color: #fff;
+ padding: 10px 18px;
+ border-radius: 8px;
+ font-size: 13px;
+ z-index: 50;
+}
+
+#text-editor-app .docname {
+ margin-left: 10px;
+ padding: 3px 10px;
+ max-width: 320px;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ background: transparent;
+ border: 1px solid rgba(156, 163, 175, 0.4);
+ border-radius: 999px;
+ font:
+ 12px/1.4 -apple-system,
+ system-ui,
+ sans-serif;
+ color: inherit;
+ opacity: 0.85;
+ cursor: pointer;
+}
+#text-editor-app .docname:hover {
+ opacity: 1;
+}
+
+#text-editor-app .textsel {
+ position: absolute;
+ background: rgba(99, 102, 241, 0.28);
+ pointer-events: none;
+}
+
+#text-editor-app .para-box.block-box {
+ border-style: dashed;
+}
+#text-editor-app .para-box.in-block,
+#text-editor-app .para-box.in-block:hover {
+ border-color: transparent;
+}
+#text-editor-app .para-box.block-box.active {
+ border: 1.5px solid var(--ecaccent);
+}
+#text-editor-app .editor.in-block > [contenteditable] {
+ outline: none;
+}
+
+#text-editor-app .ph {
+ font-size: 15px;
+ line-height: 1;
+}
+#text-editor-app .tgroup {
+ display: inline-flex;
+ align-items: center;
+ gap: 4px;
+}
+#text-editor-app .btn {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ gap: 6px;
+ height: 30px;
+ padding: 0 10px;
+}
+#text-editor-app .btn.icon {
+ width: 30px;
+ padding: 0;
+}
+#text-editor-app .btn:focus-visible,
+#text-editor-app .segmented button:focus-visible {
+ outline: 2px solid var(--ecaccent);
+ outline-offset: 1px;
+}
+#text-editor-app .btn.dirty::after {
+ content: '';
+ width: 6px;
+ height: 6px;
+ border-radius: 50%;
+ background: #f59e0b;
+ flex: 0 0 auto;
+}
+#text-editor-app #toolbar {
+ padding: 8px 12px;
+ gap: 6px;
+ row-gap: 6px;
+}
+#text-editor-app .segmented button {
+ display: inline-flex;
+ align-items: center;
+ gap: 6px;
+ height: 30px;
+}
+#text-editor-app .segmented.small button {
+ height: 26px;
+ padding: 0 8px;
+}
+#text-editor-app .barlead {
+ color: var(--ecmuted);
+ margin-right: 2px;
+}
+#text-editor-app #findbar,
+#text-editor-app #spellbar {
+ padding: 8px 12px;
+}
+#text-editor-app #toolbar select,
+#text-editor-app #findbar input,
+#text-editor-app #spellbar input {
+ height: 30px;
+}
+#text-editor-app #toolbar > select {
+ background: var(--ecpanel2);
+ color: var(--ectext);
+ border: 1px solid var(--ecline);
+ border-radius: 6px;
+ padding: 0 6px;
+}
+#text-editor-app .chk {
+ display: inline-flex;
+ align-items: center;
+ gap: 5px;
+ user-select: none;
+}
+#text-editor-app .chk input[type='checkbox'] {
+ accent-color: var(--ecaccent);
+}
+#text-editor-app #inspector {
+ flex-basis: 264px;
+}
+#text-editor-app #inspector h2 {
+ display: flex;
+ align-items: center;
+ gap: 6px;
+ font-size: 12px;
+ letter-spacing: 0.06em;
+ text-transform: uppercase;
+ color: var(--ecmuted);
+}
+#text-editor-app #inspector h2 .ph {
+ font-size: 14px;
+}
+#text-editor-app .toggles button {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+}
+#text-editor-app .num:focus,
+#text-editor-app #inspector select:focus,
+#text-editor-app #findbar input:focus,
+#text-editor-app #spellbar input:focus {
+ outline: none;
+ border-color: var(--ecaccent);
+}
+#text-editor-app .docname {
+ height: 26px;
+ display: inline-flex;
+ align-items: center;
+}
+#text-editor-app #empty .btn.big {
+ display: inline-flex;
+}
+#text-editor-app #toast {
+ border: 1px solid var(--ecline);
+ background: rgba(17, 24, 39, 0.92);
+ box-shadow: 0 8px 24px rgba(0, 0, 0, 0.4);
+}
+
+#text-editor-app #main {
+ position: relative;
+}
+#text-editor-app #viewbar {
+ position: absolute;
+ bottom: 16px;
+ left: calc((100% - 264px) / 2);
+ transform: translateX(-50%);
+ display: flex;
+ align-items: center;
+ gap: 4px;
+ padding: 4px 10px;
+ background: rgba(31, 41, 55, 0.94);
+ border: 1px solid var(--ecline);
+ border-radius: 999px;
+ box-shadow: 0 8px 24px rgba(0, 0, 0, 0.45);
+ z-index: 5;
+ backdrop-filter: blur(6px);
+}
+#text-editor-app #main:has(#inspector[hidden]) #viewbar {
+ left: 50%;
+}
+#text-editor-app #viewbar .btn {
+ background: transparent;
+ border-color: transparent;
+}
+#text-editor-app #viewbar .btn:hover {
+ background: var(--ecpanel2);
+}
+#text-editor-app #viewbar .sep {
+ margin: 0 4px;
+}
+
+#text-editor-app #stage,
+#text-editor-app #inspector {
+ scrollbar-width: thin;
+ scrollbar-color: transparent transparent;
+}
+#text-editor-app #stage.is-scrolling,
+#text-editor-app #inspector.is-scrolling {
+ scrollbar-color: rgba(156, 163, 175, 0.5) transparent;
+}
+#text-editor-app #stage::-webkit-scrollbar,
+#text-editor-app #inspector::-webkit-scrollbar {
+ width: 10px;
+ height: 10px;
+ background: transparent;
+}
+#text-editor-app #stage::-webkit-scrollbar-track,
+#text-editor-app #inspector::-webkit-scrollbar-track,
+#text-editor-app #stage::-webkit-scrollbar-corner,
+#text-editor-app #inspector::-webkit-scrollbar-corner {
+ background: transparent;
+}
+#text-editor-app #stage::-webkit-scrollbar-thumb,
+#text-editor-app #inspector::-webkit-scrollbar-thumb {
+ background: transparent;
+ border-radius: 8px;
+ border: 3px solid transparent;
+ background-clip: padding-box;
+}
+#text-editor-app #stage.is-scrolling::-webkit-scrollbar-thumb,
+#text-editor-app #inspector.is-scrolling::-webkit-scrollbar-thumb {
+ background: rgba(156, 163, 175, 0.45);
+ border: 3px solid transparent;
+ background-clip: padding-box;
+}
+
+#text-editor-app .viewopts {
+ margin-left: auto;
+ gap: 10px;
+}
+#text-editor-app #info {
+ margin-left: 12px;
+ max-width: 24vw;
+}
+
+#text-editor-app #inspector {
+ padding: 16px;
+}
+#text-editor-app #inspector .row {
+ margin-bottom: 12px;
+}
+#text-editor-app .grow {
+ flex: 1 1 auto;
+}
+#text-editor-app #fSize {
+ width: 64px;
+}
+#text-editor-app .swatchrow {
+ gap: 8px;
+}
+#text-editor-app .swatches {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 6px;
+ flex: 1 1 auto;
+}
+#text-editor-app .swatch {
+ width: 24px;
+ height: 24px;
+ border-radius: 50%;
+ border: 1px solid var(--ecline);
+}
+#text-editor-app .swatch:hover {
+ box-shadow: 0 0 0 2px var(--ecaccent);
+}
+#text-editor-app #fColor,
+#text-editor-app #fStrokeColor,
+#text-editor-app #pStrokeColor {
+ width: 26px;
+ height: 26px;
+ padding: 0;
+ border: 1px solid var(--ecline);
+ border-radius: 50%;
+ background: conic-gradient(
+ #f43f5e,
+ #f59e0b,
+ #84cc16,
+ #06b6d4,
+ #6366f1,
+ #d946ef,
+ #f43f5e
+ );
+ cursor: pointer;
+ flex: 0 0 auto;
+}
+#text-editor-app #fColor::-webkit-color-swatch-wrapper,
+#text-editor-app #fStrokeColor::-webkit-color-swatch-wrapper,
+#text-editor-app #pStrokeColor::-webkit-color-swatch-wrapper {
+ padding: 5px;
+}
+#text-editor-app #fColor::-webkit-color-swatch,
+#text-editor-app #fStrokeColor::-webkit-color-swatch,
+#text-editor-app #pStrokeColor::-webkit-color-swatch {
+ border: none;
+ border-radius: 50%;
+}
+#text-editor-app #fAlign.grow button {
+ flex: 1 1 0;
+ justify-content: center;
+ height: 32px;
+}
+#text-editor-app .toggles {
+ display: grid;
+ grid-template-columns: repeat(6, 1fr);
+ gap: 6px;
+}
+#text-editor-app .toggles button {
+ width: auto;
+ height: 32px;
+}
+#text-editor-app .rowlabel {
+ color: var(--ecmuted);
+ font-size: 12px;
+ flex: 1 1 auto;
+}
+#text-editor-app #fMarkerStyle {
+ flex: 1 1 auto;
+ min-width: 0;
+}
+#text-editor-app .field .num {
+ width: 64px;
+}
+
+#text-editor-app [hidden] {
+ display: none !important;
+}
+#text-editor-app #toolbar {
+ flex-wrap: nowrap;
+ min-height: 47px;
+}
+#text-editor-app #contextbar {
+ position: absolute;
+ top: 12px;
+ left: calc((100% - 280px) / 2);
+ transform: translateX(-50%);
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ padding: 5px 10px;
+ background: rgba(31, 41, 55, 0.94);
+ border: 1px solid var(--ecline);
+ border-radius: 12px;
+ box-shadow: 0 8px 24px rgba(0, 0, 0, 0.45);
+ z-index: 5;
+ backdrop-filter: blur(6px);
+}
+#text-editor-app #main:has(#inspector[hidden]) #contextbar {
+ left: 50%;
+}
+#text-editor-app
+ #contextbar:not(:has(.objbtn:not([hidden]))):not(
+ :has(#alignTools:not([hidden]))
+ ) {
+ display: none;
+}
+#text-editor-app #inspector {
+ flex-basis: 280px;
+}
+#text-editor-app #viewbar {
+ left: calc((100% - 280px) / 2);
+}
+#text-editor-app #inspector select {
+ min-width: 0;
+}
+#text-editor-app #inspector #fSize {
+ flex: 0 0 64px;
+}
+#text-editor-app .sect {
+ margin: 0 0 8px;
+ padding-top: 12px;
+ border-top: 1px solid var(--ecline);
+ font-size: 10px;
+ font-weight: 600;
+ letter-spacing: 0.1em;
+ text-transform: uppercase;
+ color: var(--ecmuted);
+}
+#text-editor-app #inspector h2 + .sect {
+ border-top: none;
+ padding-top: 0;
+}
+#text-editor-app #inspector .toggles {
+ display: grid;
+ grid-template-columns: repeat(6, 1fr);
+ gap: 6px;
+}
+#text-editor-app #inspector .toggles button {
+ width: 100%;
+ height: 32px;
+}
+#text-editor-app #inspector #fDir.grow {
+ display: flex;
+}
+#text-editor-app #inspector #fDir.grow button {
+ flex: 1 1 0;
+ justify-content: center;
+}
+
+#text-editor-app #inspector h2 {
+ margin-bottom: 16px;
+}
+#text-editor-app .sect {
+ margin: 0 0 12px;
+ padding-top: 18px;
+}
+#text-editor-app #inspector .row {
+ margin-bottom: 12px;
+}
+#text-editor-app #inspector .field {
+ margin-bottom: 10px;
+}
+#text-editor-app .toolright {
+ margin-left: 12px;
+ flex: 0 0 auto;
+}
+#text-editor-app #info {
+ margin-left: auto;
+}
+#text-editor-app #inspector.collapsed {
+ display: none;
+}
+#text-editor-app #main:has(#inspector[hidden]) #contextbar,
+#text-editor-app #main:has(#inspector.collapsed) #contextbar {
+ left: 50%;
+}
+#text-editor-app #toggleInspector.on {
+ background: var(--ecaccent);
+ border-color: var(--ecaccent);
+}
+
+#text-editor-app #viewbar {
+ left: auto;
+ right: 16px;
+ transform: none;
+}
+#text-editor-app #main:has(#inspector[hidden]) #viewbar,
+#text-editor-app #main:has(#inspector.collapsed) #viewbar {
+ left: auto;
+}
+#text-editor-app #viewbar .vbtools {
+ display: flex;
+ align-items: center;
+ gap: 4px;
+}
+#text-editor-app #viewbar.collapsed .vbtools {
+ display: none;
+}
+#text-editor-app #viewbar.collapsed #vbToggle i {
+ transform: rotate(180deg);
+}
+#text-editor-app #pageLabel {
+ min-width: 52px;
+ padding: 2px 6px;
+ border-radius: 6px;
+ outline: none;
+ cursor: text;
+}
+#text-editor-app #pageLabel:focus {
+ background: var(--ecpanel2);
+ color: var(--ectext);
+ box-shadow: 0 0 0 1px var(--ecaccent);
+}
+
+#text-editor-app #findbar {
+ flex: 0 0 260px;
+ display: flex;
+ flex-direction: column;
+ align-items: stretch;
+ gap: 10px;
+ padding: 14px;
+ background: var(--ecpanel);
+ border-right: 1px solid var(--ecline);
+ border-bottom: none;
+ overflow-y: auto;
+}
+#text-editor-app .findhead {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+}
+#text-editor-app .findtitle {
+ display: inline-flex;
+ align-items: center;
+ gap: 6px;
+ font-size: 12px;
+ font-weight: 600;
+ letter-spacing: 0.06em;
+ text-transform: uppercase;
+ color: var(--ecmuted);
+}
+#text-editor-app .findfield {
+ display: flex;
+ gap: 6px;
+}
+#text-editor-app .findfield input {
+ flex: 1 1 auto;
+ min-width: 0;
+}
+#text-editor-app #findbar input:not([type='checkbox']) {
+ background: var(--ecpanel2);
+ color: var(--ectext);
+ border: 1px solid var(--ecline);
+ border-radius: 6px;
+ padding: 4px 8px;
+ width: auto;
+}
+#text-editor-app #findbar #replText {
+ width: 100%;
+}
+#text-editor-app .findrow {
+ display: flex;
+ gap: 6px;
+}
+#text-editor-app .findstatus {
+ color: var(--ecmuted);
+ font-size: 12px;
+ white-space: normal;
+ line-height: 1.4;
+}
+
+#text-editor-app #stage {
+ justify-content: flex-start;
+}
+#text-editor-app #pageWrap {
+ margin-left: auto;
+ margin-right: auto;
+}
+
+#text-editor-app #docName {
+ display: none !important;
+}
+#text-editor-app .btn.on {
+ background: var(--ecaccent);
+ border-color: var(--ecaccent);
+}
+#text-editor-app #viewbar {
+ position: relative;
+ left: auto;
+ right: auto;
+ bottom: auto;
+ transform: none;
+ display: flex;
+ align-items: center;
+ width: 100%;
+ padding: 3px 10px;
+ background: var(--ecpanel);
+ border: none;
+ border-top: 1px solid var(--ecline);
+ border-radius: 0;
+ box-shadow: none;
+ backdrop-filter: none;
+ flex: 0 0 auto;
+ z-index: 4;
+}
+#text-editor-app #viewbar .btn {
+ height: 26px;
+ width: 26px;
+}
+#text-editor-app #viewbar .label {
+ font-size: 12px;
+}
+#text-editor-app .vbzoom {
+ display: flex;
+ align-items: center;
+ gap: 2px;
+}
+#text-editor-app .vbcenter {
+ position: absolute;
+ left: 50%;
+ transform: translateX(-50%);
+ display: flex;
+ align-items: center;
+ gap: 2px;
+}
+#text-editor-app #zoomHud {
+ position: absolute;
+ right: 16px;
+ bottom: 14px;
+ z-index: 6;
+ padding: 6px 12px;
+ background: rgba(31, 41, 55, 0.94);
+ border: 1px solid var(--ecline);
+ border-radius: 8px;
+ font-size: 12px;
+ font-variant-numeric: tabular-nums;
+ color: var(--ectext);
+ box-shadow: 0 6px 18px rgba(0, 0, 0, 0.4);
+ opacity: 0;
+ transition: opacity 150ms ease;
+ pointer-events: none;
+}
+#text-editor-app #zoomHud.show {
+ opacity: 1;
+}
+#text-editor-app #zoomHud[hidden] {
+ display: block !important;
+ visibility: hidden;
+}
+#text-editor-app #zoomHud.show[hidden] {
+ visibility: visible;
+}
+#text-editor-app .toolright button[data-tool] {
+ background: var(--ecpanel2);
+ color: var(--ectext);
+ border: 1px solid var(--ecline);
+ border-radius: 6px;
+ width: 30px;
+ height: 30px;
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ cursor: pointer;
+}
+#text-editor-app .toolright button[data-tool]:hover {
+ background: #4b5563;
+}
+#text-editor-app .toolright button[data-tool].on {
+ background: var(--ecaccent);
+ border-color: var(--ecaccent);
+}
+#text-editor-app #docPanel {
+ position: absolute;
+ top: 54px;
+ right: 12px;
+ z-index: 30;
+ width: 300px;
+ padding: 14px;
+ background: var(--ecpanel);
+ border: 1px solid var(--ecline);
+ border-radius: 12px;
+ box-shadow: 0 12px 32px rgba(0, 0, 0, 0.5);
+}
+#text-editor-app #docPanel h3 {
+ margin: 0 0 10px;
+ font-size: 12px;
+ font-weight: 600;
+ letter-spacing: 0.06em;
+ text-transform: uppercase;
+ color: var(--ecmuted);
+}
+#text-editor-app #docPanel dl {
+ margin: 0;
+ display: grid;
+ grid-template-columns: auto 1fr;
+ gap: 6px 12px;
+ font-size: 12px;
+}
+#text-editor-app #docPanel dt {
+ color: var(--ecmuted);
+}
+#text-editor-app #docPanel dd {
+ margin: 0;
+ color: var(--ectext);
+ overflow-wrap: anywhere;
+}
+
+#text-editor-app .editcaret {
+ font-size: 11px;
+ margin-left: 2px;
+ opacity: 0.7;
+}
+#text-editor-app .ecmenu {
+ position: fixed;
+ z-index: 40;
+ min-width: 170px;
+ padding: 6px;
+ background: #262f3f;
+ border: 1px solid var(--ecline);
+ border-radius: 12px;
+ box-shadow: 0 14px 40px rgba(0, 0, 0, 0.55);
+ display: flex;
+ flex-direction: column;
+ gap: 2px;
+}
+#text-editor-app .ecmenu-item {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ padding: 8px 12px;
+ background: transparent;
+ color: var(--ectext);
+ border: none;
+ border-radius: 8px;
+ font-size: 13px;
+ text-align: left;
+ cursor: pointer;
+}
+#text-editor-app .ecmenu-item:hover {
+ background: var(--ecpanel2);
+}
+#text-editor-app .ecmenu-item .ph-check {
+ font-size: 13px;
+ width: 14px;
+}
+#text-editor-app .ecmenu-blank {
+ width: 14px;
+ flex: 0 0 14px;
+}
+#text-editor-app .vbzoomlabel {
+ width: auto;
+ padding: 0 8px;
+ background: transparent;
+ border-color: transparent;
+ gap: 2px;
+}
+#text-editor-app .vbzoomlabel:hover {
+ background: var(--ecpanel2);
+}
+#text-editor-app .vbzoomlabel .label {
+ min-width: 40px;
+ text-align: left;
+ color: var(--ectext);
+}
+#text-editor-app .vbright {
+ margin-left: auto;
+ display: flex;
+ align-items: center;
+ gap: 2px;
+}
+#text-editor-app .pagebox {
+ min-width: 40px;
+ padding: 3px 8px;
+ background: var(--ecpanel2);
+ border: 1px solid var(--ecline);
+ border-radius: 6px;
+ color: var(--ectext);
+}
+#text-editor-app .pagebox:focus {
+ border-color: var(--ecaccent);
+ box-shadow: none;
+ background: var(--ecpanel2);
+}
+#text-editor-app #vbRestore {
+ position: absolute;
+ right: 12px;
+ bottom: 12px;
+ z-index: 6;
+ background: rgba(31, 41, 55, 0.94);
+ border: 1px solid var(--ecline);
+ border-radius: 999px;
+ box-shadow: 0 6px 18px rgba(0, 0, 0, 0.4);
+}
+#text-editor-app #docModalOverlay {
+ position: absolute;
+ inset: 0;
+ z-index: 45;
+ background: rgba(10, 14, 22, 0.6);
+ display: flex;
+ align-items: center;
+ justify-content: center;
+}
+#text-editor-app #docModal {
+ width: 440px;
+ max-width: calc(100vw - 48px);
+ max-height: 80vh;
+ overflow-y: auto;
+ padding: 18px 20px;
+ background: var(--ecpanel);
+ border: 1px solid var(--ecline);
+ border-radius: 14px;
+ box-shadow: 0 24px 64px rgba(0, 0, 0, 0.6);
+}
+#text-editor-app .docmodal-head {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ margin-bottom: 14px;
+ font-size: 14px;
+ font-weight: 600;
+}
+#text-editor-app #docModal section {
+ margin-bottom: 16px;
+}
+#text-editor-app #docModal h4 {
+ margin: 0 0 10px;
+ padding-bottom: 6px;
+ border-bottom: 1px solid var(--ecline);
+ font-size: 11px;
+ font-weight: 600;
+ letter-spacing: 0.08em;
+ text-transform: uppercase;
+ color: var(--ecmuted);
+}
+#text-editor-app #docModal dl {
+ margin: 0;
+ display: grid;
+ grid-template-columns: 110px 1fr;
+ gap: 7px 14px;
+ font-size: 12.5px;
+}
+#text-editor-app #docModal dt {
+ color: var(--ecmuted);
+}
+#text-editor-app #docModal dd {
+ margin: 0;
+ color: var(--ectext);
+ overflow-wrap: anywhere;
+}
+
+#text-editor-app .num::-webkit-inner-spin-button,
+#text-editor-app .num::-webkit-outer-spin-button {
+ -webkit-appearance: none;
+ margin: 0;
+}
+#text-editor-app .num {
+ -moz-appearance: textfield;
+ appearance: textfield;
+}
+#text-editor-app .stepper {
+ display: inline-flex;
+ align-items: stretch;
+ background: var(--ecpanel2);
+ border: 1px solid var(--ecline);
+ border-radius: 7px;
+ overflow: hidden;
+ flex: 0 0 auto;
+}
+#text-editor-app .stepper .num {
+ width: 54px;
+ border: none;
+ border-radius: 0;
+ background: transparent;
+}
+#text-editor-app .stepper .num:focus {
+ outline: none;
+ box-shadow: none;
+}
+#text-editor-app .stepper:focus-within {
+ border-color: var(--ecaccent);
+}
+#text-editor-app .stepcol {
+ display: flex;
+ flex-direction: column;
+ justify-content: center;
+ padding: 2px 3px 2px 0;
+ gap: 0;
+}
+#text-editor-app .stepbtn {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ width: 16px;
+ height: 11px;
+ background: transparent;
+ color: var(--ecmuted);
+ border: none;
+ border-radius: 3px;
+ padding: 0;
+ cursor: pointer;
+ user-select: none;
+}
+#text-editor-app .stepbtn .ph {
+ font-size: 9px;
+ line-height: 1;
+}
+#text-editor-app .stepbtn:hover {
+ background: #4b5563;
+ color: var(--ectext);
+}
+#text-editor-app .stepper:has(.num:disabled) .stepbtn {
+ opacity: 0.4;
+ cursor: default;
+}
+
+#text-editor-app #viewbar .btn {
+ width: auto;
+}
+#text-editor-app #viewbar .btn.icon {
+ width: 26px;
+}
+#text-editor-app .vbzoomlabel {
+ display: inline-flex;
+ align-items: center;
+ gap: 4px;
+ padding: 0 8px;
+}
+#text-editor-app .vbzoomlabel .label {
+ min-width: 0;
+}
+#text-editor-app #pageLabel {
+ display: none !important;
+}
+#text-editor-app .pagebox {
+ width: 44px;
+ height: 24px;
+ padding: 0 4px;
+ background: var(--ecpanel2);
+ border: 1px solid var(--ecline);
+ border-radius: 6px;
+ color: var(--ectext);
+ font-size: 12px;
+ text-align: center;
+ font-variant-numeric: tabular-nums;
+}
+#text-editor-app .pagebox::-webkit-inner-spin-button,
+#text-editor-app .pagebox::-webkit-outer-spin-button {
+ -webkit-appearance: none;
+ margin: 0;
+}
+#text-editor-app .pagebox:focus {
+ outline: none;
+ border-color: var(--ecaccent);
+}
+#text-editor-app .pagetotal {
+ min-width: 0;
+ font-size: 12px;
+}
+#text-editor-app .ecmenu {
+ min-width: 180px;
+ padding: 4px;
+ border-radius: 10px;
+ background: #232b3a;
+}
+#text-editor-app .ecmenu hr {
+ border: none;
+ border-top: 1px solid var(--ecline);
+ margin: 4px 2px;
+}
+#text-editor-app .ecmenu-item {
+ gap: 8px;
+ padding: 6px 10px;
+ border-radius: 6px;
+ font-size: 12px;
+}
+#text-editor-app .ecmenu-item.active {
+ color: var(--ecaccent);
+ font-weight: 600;
+}
+#text-editor-app .ecmenu-item .ph {
+ font-size: 14px;
+}
+#text-editor-app .zoomcustom {
+ display: flex;
+ align-items: center;
+ gap: 6px;
+ margin: 2px;
+ padding: 5px 10px;
+ background: var(--ecpanel2);
+ border-radius: 6px;
+}
+#text-editor-app .zoomcustom input {
+ flex: 1 1 auto;
+ min-width: 0;
+ background: transparent;
+ border: none;
+ outline: none;
+ color: var(--ectext);
+ font-size: 12px;
+}
+#text-editor-app .zoomcustom input::-webkit-inner-spin-button,
+#text-editor-app .zoomcustom input::-webkit-outer-spin-button {
+ -webkit-appearance: none;
+ margin: 0;
+}
+#text-editor-app .zoomcustom span {
+ color: var(--ecmuted);
+ font-size: 12px;
+}
+
+@media (max-width: 768px) {
+ #toolbar {
+ overflow-x: auto;
+ scrollbar-width: none;
+ padding: 6px 8px;
+ gap: 6px;
+ }
+ #toolbar::-webkit-scrollbar {
+ display: none;
+ }
+ #toolbar .btn {
+ height: 36px;
+ }
+ #toolbar .btn.icon,
+ #toolbar .toolright button {
+ width: 36px;
+ height: 36px;
+ }
+ #tools.segmented button {
+ height: 36px;
+ }
+ #info {
+ display: none;
+ }
+ #stage {
+ padding: 10px;
+ }
+ #findbar {
+ position: absolute;
+ left: 0;
+ right: 0;
+ top: 0;
+ width: auto;
+ flex: none;
+ max-height: 55%;
+ border-right: none;
+ border-bottom: 1px solid var(--ecline);
+ z-index: 25;
+ background: var(--ecpanel);
+ box-shadow: 0 12px 30px rgba(0, 0, 0, 0.35);
+ }
+ #contextbar {
+ left: 50% !important;
+ transform: translateX(-50%);
+ top: 8px;
+ max-width: calc(100% - 12px);
+ flex-wrap: wrap;
+ justify-content: center;
+ }
+ #viewbar {
+ justify-content: space-between;
+ gap: 4px;
+ padding: 6px 10px calc(6px + env(safe-area-inset-bottom));
+ }
+ #viewbar .btn {
+ height: 36px;
+ min-width: 36px;
+ }
+ #viewbar .btn.icon {
+ width: 36px;
+ }
+ .vbcenter {
+ position: static;
+ transform: none;
+ margin: 0 auto;
+ }
+ .pagebox {
+ width: 52px;
+ height: 32px;
+ font-size: 16px;
+ }
+ #inspector input,
+ #inspector select,
+ #findbar input,
+ #spellbar input {
+ font-size: 16px;
+ }
+ #inspector .num,
+ #inspector #fSize,
+ .field .num {
+ width: 72px;
+ }
+ .toggles button {
+ height: 40px;
+ }
+ #docPanel {
+ left: 8px;
+ right: 8px;
+ width: auto;
+ }
+ .ecmenu {
+ max-width: calc(100vw - 16px);
+ }
+ .ecmenu button {
+ min-height: 38px;
+ }
+ #vbRestore {
+ bottom: calc(12px + env(safe-area-inset-bottom));
+ }
+}
+
+@media (pointer: coarse) {
+ .stepbtn {
+ position: relative;
+ }
+ .obj-handle::after,
+ .rot-handle::after,
+ .para-handle::after,
+ .edit-move::after,
+ .stepbtn::after {
+ content: '';
+ position: absolute;
+ inset: -9px;
+ }
+}
+
+@media (max-width: 768px) {
+ #toolbar .toolright {
+ position: sticky;
+ right: -8px;
+ margin-left: auto;
+ background: var(--ecpanel);
+ box-shadow: -8px 0 8px var(--ecpanel);
+ }
+}
+
+#text-editor-app {
+ color-scheme: dark;
+}
+
+@media (max-width: 768px) {
+ #findbar {
+ top: auto;
+ bottom: 0;
+ max-height: 70%;
+ border-bottom: none;
+ border-top: 1px solid var(--ecline);
+ border-radius: 16px 16px 0 0;
+ background: #1f2937;
+ background: var(--ecpanel, #1f2937);
+ box-shadow: 0 -12px 30px rgba(0, 0, 0, 0.45);
+ z-index: 27;
+ padding: 8px 14px calc(14px + env(safe-area-inset-bottom));
+ }
+ #findbar::before {
+ content: '';
+ display: block;
+ flex: 0 0 5px;
+ width: 44px;
+ height: 5px;
+ border-radius: 3px;
+ background: var(--ecline);
+ margin: 2px auto 8px;
+ }
+ #findbar:not([hidden]) {
+ animation: ecSheetUp 0.22s ease-out;
+ }
+}
+
+@keyframes ecSheetUp {
+ from {
+ transform: translateY(28px);
+ opacity: 0.5;
+ }
+ to {
+ transform: none;
+ opacity: 1;
+ }
+}
+
+#text-editor-app .ecgrab {
+ display: none;
+}
+
+@media (max-width: 768px) {
+ #findbar:not([hidden]) {
+ animation: none;
+ }
+ #findbar::before {
+ content: none;
+ }
+ #text-editor-app .ecgrab {
+ display: flex;
+ justify-content: center;
+ align-items: center;
+ flex: 0 0 auto;
+ height: 28px;
+ position: sticky;
+ top: 0;
+ z-index: 2;
+ background: inherit;
+ touch-action: none;
+ }
+ #text-editor-app .ecgrab span {
+ width: 44px;
+ height: 5px;
+ border-radius: 3px;
+ background: var(--ecline);
+ }
+ #findDone {
+ display: none;
+ }
+ #toolbar {
+ min-height: 52px;
+ gap: 8px;
+ padding: 8px;
+ }
+ #toolbar > *,
+ #toolbar .tgroup,
+ #toolbar .tgroup > *,
+ #tools.segmented,
+ #toolbar .sep {
+ flex-shrink: 0;
+ }
+ #toolbar .btn {
+ height: 38px;
+ padding: 0 12px;
+ }
+ #toolbar .btn.icon,
+ #toolbar .toolright button {
+ width: 38px;
+ height: 38px;
+ padding: 0;
+ }
+ #tools.segmented button {
+ height: 38px;
+ }
+ #viewbar {
+ padding: 8px 10px calc(8px + env(safe-area-inset-bottom));
+ }
+ #viewbar > * {
+ flex-shrink: 0;
+ }
+ #viewbar .btn {
+ height: 38px;
+ min-width: 38px;
+ }
+ #viewbar .btn.icon {
+ width: 38px;
+ }
+ .pagebox {
+ width: 56px;
+ height: 36px;
+ }
+}
+
+@media (max-width: 768px) {
+ #text-editor-app #findDone {
+ display: none;
+ }
+ #text-editor-app #viewbar button {
+ height: 38px;
+ min-width: 38px;
+ }
+ #text-editor-app #viewbar .btn.icon {
+ width: 38px;
+ }
+ #text-editor-app #viewbar .pagebox {
+ width: 56px;
+ height: 36px;
+ }
+ #text-editor-app #viewbar #zoomMenuBtn {
+ width: auto;
+ padding: 0 8px;
+ }
+}
+
+@media (max-width: 768px) {
+ #text-editor-app #viewbar {
+ padding: 8px 10px calc(8px + env(safe-area-inset-bottom));
+ gap: 4px;
+ justify-content: space-between;
+ }
+ #text-editor-app #viewbar .btn {
+ height: 38px;
+ min-width: 38px;
+ }
+ #text-editor-app #viewbar .btn.icon {
+ width: 38px;
+ }
+ #text-editor-app #viewbar .pagebox {
+ width: 56px;
+ height: 36px;
+ font-size: 16px;
+ }
+ #text-editor-app #viewbar .label {
+ font-size: 14px;
+ }
+}
+
+@media (max-width: 768px) {
+ #text-editor-app #findbar {
+ isolation: isolate;
+ transform: translateZ(0);
+ backface-visibility: hidden;
+ }
+}
+
+@media (max-width: 768px) {
+ #text-editor-app #zoomIn,
+ #text-editor-app #zoomOut {
+ display: none;
+ }
+ #toolbar .sep {
+ display: none;
+ }
+ #toolbar .toolright {
+ position: static;
+ box-shadow: none;
+ margin-left: auto;
+ background: none;
+ }
+}
+
+#text-editor-app .ectoast {
+ position: fixed;
+ left: 50%;
+ bottom: calc(84px + env(safe-area-inset-bottom));
+ transform: translateX(-50%);
+ max-width: calc(100vw - 40px);
+ padding: 10px 16px;
+ border-radius: 10px;
+ background: #000c;
+ color: #fff;
+ font-size: 14px;
+ text-align: center;
+ z-index: 50;
+ pointer-events: none;
+}
+
+@media (max-width: 768px) {
+ #text-editor-app .toolright button[data-tool] {
+ width: 38px;
+ height: 38px;
+ }
+ #text-editor-app #viewbar .label,
+ #text-editor-app #viewbar .vbzoomlabel .label {
+ font-size: 15px;
+ }
+ #text-editor-app #viewbar .pagetotal {
+ font-size: 15px;
+ }
+}
+
+#text-editor-app .segmented button:last-of-type {
+ border-right: none;
+}
+#text-editor-app .editcaret {
+ transition: transform 0.15s ease;
+}
+#text-editor-app .editcaret.up {
+ transform: rotate(180deg);
+}
+#text-editor-app .ecmenu-icon {
+ font-size: 15px;
+ opacity: 0.85;
+}
+
+@media (max-width: 768px) {
+ #text-editor-app #findbar {
+ touch-action: pan-y;
+ overscroll-behavior: contain;
+ }
+ #text-editor-app.kb-open #viewbar {
+ display: none;
+ }
+}
+
+@media (max-width: 768px) {
+ #text-editor-app #toolbar #undo {
+ margin-left: auto;
+ }
+}
+
+#text-editor-app #main {
+ background: var(--ecstage);
+}
+#text-editor-app #main::before {
+ content: '';
+ position: absolute;
+ inset: 0;
+ pointer-events: none;
+ z-index: 0;
+ background-image:
+ radial-gradient(
+ 120% 85% at 50% 28%,
+ rgba(148, 163, 184, 0.11),
+ transparent 62%
+ ),
+ url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='160' height='160'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='.9' numOctaves='2' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='160' height='160' filter='url(%23n)' opacity='.045'/%3E%3C/svg%3E");
+ background-size:
+ auto,
+ 160px 160px;
+}
+#text-editor-app #stage {
+ background: transparent;
+}
+#text-editor-app #pageWrap {
+ margin: auto;
+ box-shadow:
+ 0 1px 2px rgba(0, 0, 0, 0.4),
+ 0 18px 44px -12px rgba(0, 0, 0, 0.6);
+ outline: 1px solid rgba(255, 255, 255, 0.07);
+}
+
+@media (max-width: 768px) {
+ #text-editor-app #stage {
+ padding: 12px 10px;
+ }
+}
+
+@media (max-width: 768px) {
+ #text-editor-app #tools,
+ #text-editor-app #toggleInspector,
+ #text-editor-app #contextbar,
+ #text-editor-app #find,
+ #text-editor-app #addImage,
+ #text-editor-app .toolright [data-tool='addText'],
+ #text-editor-app .toolright .sep,
+ #text-editor-app #toolbar label[for='file'],
+ #text-editor-app #save span,
+ #text-editor-app #viewbar,
+ #text-editor-app #vbRestore {
+ display: none !important;
+ }
+ #text-editor-app #toolbar {
+ flex-wrap: nowrap;
+ overflow: visible;
+ min-height: 52px;
+ }
+ #text-editor-app #toolbar > .tgroup:first-of-type {
+ flex: 1 1 auto;
+ min-width: 0;
+ gap: 6px;
+ }
+ #text-editor-app #toolbar #exitEditor {
+ order: 0;
+ }
+ #text-editor-app #toolbar #undo {
+ order: 1;
+ margin-left: 0;
+ }
+ #text-editor-app #toolbar #redo {
+ order: 2;
+ }
+ #text-editor-app #toolbar .dpagepill {
+ order: 3;
+ margin-left: auto;
+ }
+ #text-editor-app #toolbar #save {
+ order: 4;
+ width: 38px;
+ padding: 0;
+ }
+ #text-editor-app .dpagepill {
+ flex: 0 0 auto;
+ height: 34px;
+ padding: 0 14px;
+ border-radius: 999px;
+ border: 1px solid var(--ecline);
+ background: transparent;
+ color: var(--ectext);
+ font-size: 13px;
+ font-variant-numeric: tabular-nums;
+ white-space: nowrap;
+ cursor: pointer;
+ }
+ #text-editor-app .dpagemenu {
+ min-width: 230px;
+ }
+ #text-editor-app .dpagenav {
+ display: flex;
+ align-items: center;
+ gap: 6px;
+ padding: 4px;
+ }
+ #text-editor-app .dnavbtn {
+ width: 42px;
+ height: 42px;
+ border-radius: 8px;
+ border: 1px solid var(--ecline);
+ background: var(--ecpanel2);
+ color: var(--ectext);
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ cursor: pointer;
+ font-size: 14px;
+ }
+ #text-editor-app .dnavbtn.dgo {
+ width: auto;
+ padding: 0 16px;
+ background: var(--ecaccent);
+ border-color: var(--ecaccent);
+ font-weight: 600;
+ }
+ #text-editor-app .dpageinp {
+ flex: 1 1 auto;
+ min-width: 0;
+ width: 60px;
+ height: 42px;
+ text-align: center;
+ background: var(--ecbg);
+ border: 1px solid var(--ecline);
+ border-radius: 8px;
+ color: var(--ectext);
+ font-size: 16px;
+ font-variant-numeric: tabular-nums;
+ }
+ #text-editor-app .dpageinp:focus {
+ outline: none;
+ border-color: var(--ecaccent);
+ }
+ #text-editor-app .dpageinp::-webkit-inner-spin-button,
+ #text-editor-app .dpageinp::-webkit-outer-spin-button {
+ -webkit-appearance: none;
+ margin: 0;
+ }
+ #text-editor-app #main {
+ flex-direction: column;
+ }
+ #text-editor-app #main #stage {
+ min-height: 0;
+ min-width: 0;
+ }
+ #text-editor-app #inspector {
+ position: static;
+ flex: 0 0 auto;
+ width: auto;
+ max-height: none;
+ margin: 0;
+ margin-bottom: var(--eckb, 0px);
+ padding: 0;
+ background: var(--ecpanel);
+ border: none;
+ border-top: 1px solid var(--ecline);
+ border-radius: 16px 16px 0 0;
+ box-shadow: 0 -12px 30px rgba(0, 0, 0, 0.35);
+ overflow: visible;
+ z-index: 26;
+ }
+ #text-editor-app #inspector[disabled] {
+ opacity: 1;
+ pointer-events: auto;
+ }
+ #text-editor-app
+ #inspector
+ > :not(.dockpanel):not(.dockrail):not(.dsheet):not(.ddim) {
+ display: none;
+ }
+ #text-editor-app .dockpanel {
+ display: grid;
+ grid-template-rows: 0fr;
+ transition: grid-template-rows 250ms cubic-bezier(0.32, 0.72, 0, 1);
+ }
+ #text-editor-app .dockpanel.open {
+ grid-template-rows: 1fr;
+ }
+ #text-editor-app .dockclip {
+ min-height: 0;
+ overflow: hidden;
+ }
+ #text-editor-app .dockbody {
+ padding: 14px 16px 12px;
+ transform: translateY(12px);
+ opacity: 0;
+ transition:
+ transform 250ms cubic-bezier(0.32, 0.72, 0, 1),
+ opacity 180ms ease;
+ }
+ #text-editor-app .dockpanel.open .dockbody {
+ transform: none;
+ opacity: 1;
+ }
+ #text-editor-app .dockbody.fading {
+ opacity: 0;
+ transform: translateY(6px);
+ transition-duration: 120ms;
+ }
+ #text-editor-app .dockhead {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ margin-bottom: 12px;
+ }
+ #text-editor-app .dtitle {
+ display: flex;
+ align-items: center;
+ gap: 7px;
+ font-size: 11px;
+ font-weight: 600;
+ letter-spacing: 0.1em;
+ text-transform: uppercase;
+ color: var(--ecmuted);
+ }
+ #text-editor-app .dtitle .ph {
+ font-size: 15px;
+ color: var(--ecaccent);
+ }
+ #text-editor-app .dclose {
+ width: 32px;
+ height: 32px;
+ border: none;
+ border-radius: 8px;
+ background: transparent;
+ color: var(--ecmuted);
+ font-size: 15px;
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ }
+ #text-editor-app .dclose:active {
+ background: var(--ecpanel2);
+ }
+ #text-editor-app .dockslots {
+ max-height: 42vh;
+ overflow-y: auto;
+ overscroll-behavior: contain;
+ padding: 4px 4px 10px;
+ }
+ #text-editor-app .dslot {
+ display: none;
+ }
+ #text-editor-app .dslot.act {
+ display: block;
+ }
+ #text-editor-app .dslot .row {
+ margin-bottom: 12px;
+ }
+ #text-editor-app .dslot .segmented {
+ display: flex;
+ flex: 1 1 auto;
+ }
+ #text-editor-app .dslot .segmented button {
+ flex: 1 1 0;
+ height: 44px;
+ justify-content: center;
+ }
+ #text-editor-app .dslot .toggles {
+ display: grid;
+ grid-template-columns: repeat(6, 1fr);
+ gap: 8px;
+ }
+ #text-editor-app .dslot .toggles button {
+ height: 44px;
+ }
+ #text-editor-app .dslot .swatches {
+ gap: 10px;
+ padding: 4px 2px;
+ row-gap: 12px;
+ }
+ #text-editor-app .dslot .swatch {
+ width: 32px;
+ height: 32px;
+ }
+ #text-editor-app .dslot #fColor,
+ #text-editor-app .dslot #fStrokeColor,
+ #text-editor-app .dslot #pStrokeColor {
+ width: 32px;
+ height: 32px;
+ }
+ #text-editor-app .dslot #fMarkerStyle {
+ height: 44px;
+ }
+ #text-editor-app .dsublab {
+ margin: 2px 0 8px;
+ }
+ #text-editor-app .dsizer {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ gap: 14px;
+ padding: 2px 0 12px;
+ }
+ #text-editor-app .dstep {
+ width: 58px;
+ height: 46px;
+ border-radius: 12px;
+ border: 1px solid var(--ecline);
+ background: var(--ecpanel2);
+ color: var(--ectext);
+ font-size: 18px;
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ }
+ #text-editor-app .dstep:active {
+ background: var(--ecline);
+ }
+ #text-editor-app .dsizer .stepper {
+ height: 46px;
+ border-radius: 12px;
+ background: var(--ecbg);
+ }
+ #text-editor-app .dsizer .stepper .num {
+ width: 84px;
+ height: 44px;
+ font-size: 19px;
+ font-weight: 600;
+ text-align: center;
+ }
+ #text-editor-app .dsizer .stepcol {
+ display: none;
+ }
+ #text-editor-app .dfield {
+ display: grid;
+ grid-template-columns: 88px 1fr auto;
+ align-items: center;
+ gap: 10px;
+ padding: 5px 0;
+ }
+ #text-editor-app .dlab {
+ font-size: 12px;
+ color: var(--ecmuted);
+ }
+ #text-editor-app .dfield input[type='range'] {
+ -webkit-appearance: none;
+ appearance: none;
+ width: 100%;
+ height: 28px;
+ background: transparent;
+ margin: 0;
+ }
+ #text-editor-app .dfield input[type='range']::-webkit-slider-runnable-track {
+ height: 4px;
+ border-radius: 2px;
+ background: var(--ecline);
+ }
+ #text-editor-app .dfield input[type='range']::-webkit-slider-thumb {
+ -webkit-appearance: none;
+ width: 22px;
+ height: 22px;
+ border-radius: 50%;
+ background: #fff;
+ border: none;
+ margin-top: -9px;
+ box-shadow: 0 2px 6px rgba(0, 0, 0, 0.5);
+ }
+ #text-editor-app .dfield input[type='range']::-moz-range-track {
+ height: 4px;
+ border-radius: 2px;
+ background: var(--ecline);
+ }
+ #text-editor-app .dfield input[type='range']::-moz-range-thumb {
+ width: 22px;
+ height: 22px;
+ border-radius: 50%;
+ background: #fff;
+ border: none;
+ }
+ #text-editor-app .dscope {
+ display: grid;
+ grid-template-columns: repeat(4, 1fr);
+ gap: 8px;
+ padding: 2px 0 12px;
+ }
+ #text-editor-app .dscopebtn {
+ height: 44px;
+ border-radius: 10px;
+ border: 1px solid var(--ecline);
+ background: var(--ecpanel2);
+ color: var(--ectext);
+ font-size: 12.5px;
+ font-weight: 600;
+ }
+ #text-editor-app .dscopebtn.on {
+ background: var(--ecaccent);
+ border-color: var(--ecaccent);
+ }
+ #text-editor-app .dockrail {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ padding: 9px 10px calc(9px + env(safe-area-inset-bottom));
+ }
+ #text-editor-app .dockchips {
+ display: flex;
+ gap: 6px;
+ flex: 1 1 auto;
+ min-width: 0;
+ overflow-x: auto;
+ scrollbar-width: none;
+ touch-action: pan-x;
+ -webkit-mask-image: linear-gradient(90deg, #000 92%, transparent);
+ mask-image: linear-gradient(90deg, #000 92%, transparent);
+ }
+ #text-editor-app .dockchips::-webkit-scrollbar {
+ display: none;
+ }
+ #text-editor-app .dchip {
+ flex: 0 0 auto;
+ width: 44px;
+ height: 42px;
+ padding: 0;
+ border-radius: 10px;
+ border: 1px solid var(--ecline);
+ background: var(--ecpanel2);
+ color: var(--ectext);
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ transition:
+ background 150ms ease,
+ border-color 150ms ease;
+ }
+ #text-editor-app .dchip .ph {
+ font-size: 18px;
+ }
+ #text-editor-app .dchip.on {
+ background: var(--ecaccent);
+ border-color: var(--ecaccent);
+ }
+ #text-editor-app .dockacts {
+ display: flex;
+ gap: 6px;
+ flex: 0 0 auto;
+ padding-left: 8px;
+ border-left: 1px solid var(--ecline);
+ }
+ #text-editor-app .ddel {
+ color: #f87171;
+ }
+ #text-editor-app .ddim {
+ position: fixed;
+ inset: 0;
+ z-index: 59;
+ background: rgba(4, 6, 12, 0.55);
+ opacity: 0;
+ pointer-events: none;
+ transition: opacity 250ms ease;
+ }
+ #text-editor-app .ddim.show {
+ opacity: 1;
+ pointer-events: auto;
+ }
+ #text-editor-app .dsheet {
+ position: fixed;
+ left: 0;
+ right: 0;
+ bottom: 0;
+ z-index: 60;
+ background: var(--ecpanel);
+ border-top: 1px solid var(--ecline);
+ border-radius: 18px 18px 0 0;
+ transform: translateY(105%);
+ transition: transform 300ms cubic-bezier(0.32, 0.72, 0, 1);
+ max-height: 70%;
+ display: flex;
+ flex-direction: column;
+ box-shadow: 0 -16px 40px rgba(0, 0, 0, 0.5);
+ }
+ #text-editor-app .dsheet.open {
+ transform: translateY(calc(-1 * var(--eckb, 0px)));
+ }
+ #text-editor-app .dsheet.drag {
+ transition: none;
+ }
+ #text-editor-app .dgrab {
+ padding: 10px 0 6px;
+ touch-action: none;
+ flex: 0 0 auto;
+ cursor: grab;
+ }
+ #text-editor-app .dgrab span {
+ display: block;
+ width: 44px;
+ height: 5px;
+ border-radius: 3px;
+ background: var(--ecline);
+ margin: 0 auto;
+ }
+ #text-editor-app .dsheethead {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ padding: 2px 18px 12px;
+ font-size: 15px;
+ font-weight: 600;
+ flex: 0 0 auto;
+ }
+ #text-editor-app .dsheethead .ph {
+ color: var(--ecaccent);
+ font-size: 17px;
+ }
+ #text-editor-app .dfontlist {
+ overflow-y: auto;
+ overscroll-behavior: contain;
+ padding: 0 8px calc(16px + env(safe-area-inset-bottom));
+ }
+ #text-editor-app .dfrow {
+ width: 100%;
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ padding: 14px 12px;
+ border: none;
+ background: transparent;
+ color: var(--ectext);
+ font-size: 18px;
+ border-radius: 10px;
+ text-align: left;
+ }
+ #text-editor-app .dfrow:active {
+ background: var(--ecpanel2);
+ }
+ #text-editor-app .dfrow .dfchk {
+ color: var(--ecaccent);
+ font-size: 16px;
+ visibility: hidden;
+ }
+ #text-editor-app .dfrow.on .dfchk {
+ visibility: visible;
+ }
+ #text-editor-app .ddivider {
+ height: 1px;
+ background: var(--ecline);
+ margin: 6px 12px;
+ }
+ #text-editor-app .dfrow.dfsys {
+ font-size: 14px;
+ color: var(--ecmuted);
+ font-family: inherit;
+ }
+ #text-editor-app .dchip,
+ #text-editor-app .dstep,
+ #text-editor-app .dscopebtn,
+ #text-editor-app .dfrow,
+ #text-editor-app .dclose {
+ cursor: pointer;
+ }
+ #text-editor-app #inspector .dslot .segmented button,
+ #text-editor-app #inspector .toggles button {
+ height: 44px;
+ }
+ #text-editor-app #fAlign.grow button {
+ height: 44px;
+ }
+ #text-editor-app #inspector #fDir.grow button {
+ height: 44px;
+ }
+ #text-editor-app #inspector #fSize {
+ flex: 0 0 84px;
+ width: 84px;
+ }
+}
+
+@media (max-width: 768px) and (prefers-reduced-motion: reduce) {
+ #text-editor-app .dockpanel,
+ #text-editor-app .dockbody,
+ #text-editor-app .dsheet,
+ #text-editor-app .ddim,
+ #text-editor-app .dchip {
+ transition: none !important;
+ }
+}
+
+@media (max-width: 768px) {
+ #text-editor-app #toast,
+ #text-editor-app .ectoast {
+ bottom: calc(var(--ecdock, 64px) + var(--eckb, 0px) + 12px);
+ left: 12px;
+ right: 12px;
+ transform: none;
+ max-width: none;
+ width: auto;
+ padding: 12px 16px;
+ font-size: 14px;
+ line-height: 1.45;
+ text-align: left;
+ text-wrap: pretty;
+ z-index: 55;
+ }
+}
diff --git a/src/js/canvasEditor.ts b/src/js/canvasEditor.ts
index d8cd83e5..cfba64cf 100644
--- a/src/js/canvasEditor.ts
+++ b/src/js/canvasEditor.ts
@@ -6,11 +6,7 @@ import { icons, createIcons } from 'lucide';
import * as pdfjsLib from 'pdfjs-dist';
import type { PDFDocumentProxy, PDFPageProxy } from 'pdfjs-dist';
import type { CropBox } from '@/types';
-
-pdfjsLib.GlobalWorkerOptions.workerSrc = new URL(
- 'pdfjs-dist/build/pdf.worker.min.mjs',
- import.meta.url
-).toString();
+import './utils/setup-pdf-worker.js';
const editorState: {
pdf: PDFDocumentProxy | null;
diff --git a/src/js/config/editor-fonts.ts b/src/js/config/editor-fonts.ts
new file mode 100644
index 00000000..6e45ba54
--- /dev/null
+++ b/src/js/config/editor-fonts.ts
@@ -0,0 +1,53 @@
+const FONT_CDN =
+ import.meta.env.VITE_EMBEDPDF_FONTS_URL ||
+ 'https://cdn.jsdelivr.net/npm/@embedpdf';
+
+const CHARSET = {
+ ANSI: 0,
+ DEFAULT: 1,
+ SHIFTJIS: 128,
+ HANGEUL: 129,
+ GB2312: 134,
+ CHINESEBIG5: 136,
+ GREEK: 161,
+ TURKISH: 162,
+ VIETNAMESE: 163,
+ HEBREW: 177,
+ ARABIC: 178,
+ BALTIC: 186,
+ RUSSIAN: 204,
+ THAI: 222,
+ EASTEUROPE: 238,
+} as const;
+
+const PACK = {
+ jp: 'fonts-jp@1.0.0/fonts/NotoSansJP-Regular.otf',
+ kr: 'fonts-kr@1.0.0/fonts/NotoSansKR-Regular.otf',
+ sc: 'fonts-sc@1.0.0/fonts/NotoSansHans-Regular.otf',
+ tc: 'fonts-tc@1.0.0/fonts/NotoSansHant-Regular.otf',
+ arabic: 'fonts-arabic@1.0.0/fonts/NotoNaskhArabic-Regular.ttf',
+ hebrew: 'fonts-hebrew@1.0.0/fonts/NotoSansHebrew-Regular.ttf',
+ latin: 'fonts-latin@1.0.0/fonts/NotoSans-Regular.ttf',
+} as const;
+
+export const editorFontFallback = {
+ baseUrl: FONT_CDN,
+ fonts: {
+ [CHARSET.SHIFTJIS]: PACK.jp,
+ [CHARSET.HANGEUL]: PACK.kr,
+ [CHARSET.GB2312]: PACK.sc,
+ [CHARSET.CHINESEBIG5]: PACK.tc,
+ [CHARSET.ARABIC]: PACK.arabic,
+ [CHARSET.HEBREW]: PACK.hebrew,
+ [CHARSET.GREEK]: PACK.latin,
+ [CHARSET.TURKISH]: PACK.latin,
+ [CHARSET.VIETNAMESE]: PACK.latin,
+ [CHARSET.BALTIC]: PACK.latin,
+ [CHARSET.RUSSIAN]: PACK.latin,
+ [CHARSET.EASTEUROPE]: PACK.latin,
+ [CHARSET.THAI]: PACK.latin,
+ [CHARSET.ANSI]: PACK.latin,
+ [CHARSET.DEFAULT]: PACK.latin,
+ },
+ defaultFont: PACK.latin,
+};
diff --git a/src/js/config/tools.ts b/src/js/config/tools.ts
index 79fb3597..88b67fd1 100644
--- a/src/js/config/tools.ts
+++ b/src/js/config/tools.ts
@@ -3,6 +3,13 @@ const baseCategories = [
{
name: 'Popular Tools',
tools: [
+ {
+ href: import.meta.env.BASE_URL + 'edit-pdf-text.html',
+ name: 'Edit PDF Text',
+ icon: 'ph-cursor-text',
+ subtitle:
+ 'Click any paragraph and edit it in place with live reflow, fonts, and styling.',
+ },
{
href: import.meta.env.BASE_URL + 'pdf-workflow.html',
name: 'PDF Workflow Builder',
@@ -15,7 +22,7 @@ const baseCategories = [
name: 'PDF Multi Tool',
icon: 'ph-pencil-ruler',
subtitle:
- 'Merge, Split, Organize, Delete, Rotate, Add Blank Pages, Extract and Duplicate in an unified interface.',
+ 'Merge, Split, Organize, Delete, Rotate, Add Blank Pages, Extract and Duplicate in a unified interface.',
},
{
href: import.meta.env.BASE_URL + 'merge-pdf.html',
@@ -40,7 +47,7 @@ const baseCategories = [
name: 'PDF Editor',
icon: 'ph-pencil-simple',
subtitle:
- 'Annotate, highlight, redact, comment, add shapes/images, search, and view PDFs',
+ 'Annotate, highlight, redact, comment, add shapes/images, search, and view PDFs.',
},
{
href: import.meta.env.BASE_URL + 'jpg-to-pdf.html',
@@ -90,6 +97,13 @@ const baseCategories = [
subtitle:
'Annotate, highlight, redact, comment, add shapes/images, search, and view PDFs.',
},
+ {
+ href: import.meta.env.BASE_URL + 'edit-pdf-text.html',
+ name: 'Edit PDF Text',
+ icon: 'ph-cursor-text',
+ subtitle:
+ 'Click any paragraph and edit it in place with live reflow, fonts, and styling.',
+ },
{
href: import.meta.env.BASE_URL + 'bookmark.html',
name: 'Edit Bookmarks',
@@ -537,7 +551,14 @@ const baseCategories = [
name: 'Alternate & Mix Pages',
icon: 'ph-shuffle',
subtitle:
- 'Merge PDFs by alternating pages from each PDF. Preserves Bookmarks',
+ 'Merge PDFs by alternating pages from each PDF. Preserves Bookmarks.',
+ },
+ {
+ href: import.meta.env.BASE_URL + 'duplex-collate.html',
+ name: 'Duplex Collate',
+ icon: 'ph-arrows-vertical',
+ subtitle:
+ 'Reorder front/back scan blocks into collated page order in one PDF.',
},
{
href: import.meta.env.BASE_URL + 'organize-pdf.html',
diff --git a/src/js/editcore/app.js b/src/js/editcore/app.js
new file mode 100644
index 00000000..a270cd21
--- /dev/null
+++ b/src/js/editcore/app.js
@@ -0,0 +1,10263 @@
+window.addEventListener('error', (e) => {
+ if (!document.title.startsWith('E2E') && !document.title.startsWith('REPRO'))
+ document.title =
+ 'JSERROR: ' +
+ e.message +
+ ' @' +
+ (e.filename || '').split('/').pop() +
+ ':' +
+ e.lineno;
+});
+window.addEventListener('unhandledrejection', (e) => {
+ document.title =
+ 'UNHANDLED: ' +
+ ((e.reason && (e.reason.stack || e.reason.message)) || e.reason);
+});
+import { PdfEngine, OBJ } from './core.js';
+import { applyTagSurgery } from './tagsurgery.js';
+import { pageHasPatternFill, protectPatternArtwork } from './shadingsurgery.js';
+import {
+ protectType3Text,
+ protectFragileText,
+ consolidateContentArrays,
+} from './type3surgery.js';
+
+const $ = (id) => document.getElementById(id);
+const state = {
+ engine: null,
+ docEpoch: 0,
+ textSel: null,
+ zoom: 1,
+ tool: 'edit',
+ editScope: 'all',
+ notifiedSubs: new Set(),
+ docInfo: null,
+ pdfa: false,
+ guides: { v: [], h: [] },
+ paragraphs: [],
+ selection: null,
+ editing: null,
+ dirty: false,
+ fileName: 'document.pdf',
+ undo: [],
+ redo: [],
+};
+const MAX_UNDO = 20;
+const MAX_UNDO_BYTES = 256 << 20;
+const INCREMENTAL_SNAPSHOT_LIMIT = 12 << 20;
+
+function pushUndo(entry) {
+ state.undo.push(entry);
+ if (state.undo.length > MAX_UNDO) state.undo.shift();
+ let held = 0;
+ for (let i = state.undo.length - 1; i >= 0; i--) {
+ held += state.undo[i].bytes?.length || 0;
+ if (held > MAX_UNDO_BYTES && i > 0) {
+ state.undo.splice(0, i);
+ break;
+ }
+ }
+ state.redo = [];
+}
+
+const WEB_FALLBACK = (family, kind) => {
+ if (kind === 'mono') return `"${family}", 'Courier New', monospace`;
+ if (kind === 'serif') return `"${family}", 'Times New Roman', Times, serif`;
+ const f = (family || '').toLowerCase();
+ if (f.includes('georgia')) return "Georgia, 'Times New Roman', serif";
+ if (f.includes('courier') || f.includes('mono'))
+ return "'Courier New', monospace";
+ if (
+ f.includes('times') ||
+ f.includes('serif') ||
+ f.includes('roman') ||
+ f.includes('minion') ||
+ f.includes('garamond') ||
+ f.includes('book')
+ )
+ return "'Times New Roman', Times, serif";
+ if (family && !/helvetica|arial/i.test(family))
+ return `"${family}", Helvetica, Arial, sans-serif`;
+ return 'Helvetica, Arial, sans-serif';
+};
+
+const rgbaToCss = (n) =>
+ `rgba(${(n >>> 24) & 255}, ${(n >>> 16) & 255}, ${(n >>> 8) & 255}, ${(n & 255) / 255})`;
+const cssHexToRgba = (hex) => {
+ const r = parseInt(hex.slice(1, 3), 16),
+ g = parseInt(hex.slice(3, 5), 16),
+ b = parseInt(hex.slice(5, 7), 16);
+ return ((r << 24) | (g << 16) | (b << 8) | 255) >>> 0;
+};
+const rgbaToHex = (n) => {
+ const c = (v) => v.toString(16).padStart(2, '0');
+ return '#' + c((n >>> 24) & 255) + c((n >>> 16) & 255) + c((n >>> 8) & 255);
+};
+
+function toast(msg) {
+ const t = $('toast');
+ t.textContent = msg;
+ t.hidden = false;
+ clearTimeout(toast._t);
+ toast._t = setTimeout(() => (t.hidden = true), 2200);
+}
+
+const P = () => state.engine;
+const paraRectView = (box) => ({
+ x: box.x * state.zoom,
+ y: (P().pageHeight - box.top) * state.zoom,
+ w: box.w * state.zoom,
+ h: box.h * state.zoom,
+});
+
+const rotRad = (para) => ((para?.rotation || 0) * Math.PI) / 180;
+const toPage = (e) => {
+ const rect = $('page').getBoundingClientRect();
+ return {
+ px: (e.clientX - rect.left) / state.zoom,
+ py: P().pageHeight - (e.clientY - rect.top) / state.zoom,
+ };
+};
+
+const textToPage = (para, x, y) => {
+ const t = rotRad(para);
+ if (!t) return { x, y };
+ const c = Math.cos(t),
+ s = Math.sin(t);
+ return { x: x * c - y * s, y: x * s + y * c };
+};
+const pageToText = (para, x, y) => {
+ const t = rotRad(para);
+ if (!t) return { x, y };
+ const c = Math.cos(t),
+ s = Math.sin(t);
+ return { x: x * c + y * s, y: -x * s + y * c };
+};
+const paraPlacement = (para) => {
+ const b = para.box;
+ const tl = textToPage(para, b.x, b.top);
+ return {
+ x: tl.x * state.zoom,
+ y: (P().pageHeight - tl.y) * state.zoom,
+ w: b.w * state.zoom,
+ h: b.h * state.zoom,
+ deg: -(para.rotation || 0),
+ };
+};
+const BOX_PAD = 0;
+const applyPlacement = (el, pl, out = 0) => {
+ Object.assign(el.style, {
+ left: pl.x - out + 'px',
+ top: pl.y - out + 'px',
+ width: pl.w + 2 * out + 'px',
+ height: pl.h + 2 * out + 'px',
+ });
+ if (pl.deg) {
+ el.style.transform = `rotate(${pl.deg}deg)`;
+ el.style.transformOrigin = '0 0';
+ }
+};
+const EDITOR_PAD = 2;
+function positionWrap(el, para) {
+ const pl = paraPlacement(para);
+ el.style.left = pl.x + 'px';
+ el.style.top = pl.y + 'px';
+ el.style.transformOrigin = '0 0';
+ el.style.transform = pl.deg
+ ? `rotate(${pl.deg}deg) translate(${-EDITOR_PAD}px, ${-EDITOR_PAD}px)`
+ : `translate(${-EDITOR_PAD}px, ${-EDITOR_PAD}px)`;
+}
+const objRectView = (b) => ({
+ x: b.x * state.zoom,
+ y: (P().pageHeight - (b.y + b.h)) * state.zoom,
+ w: b.w * state.zoom,
+ h: b.h * state.zoom,
+});
+
+let engineReadyResolve;
+const engineReady = new Promise((resolve) => {
+ engineReadyResolve = resolve;
+});
+
+(async function init() {
+ try {
+ state.engine = await PdfEngine.create();
+ } catch (e) {
+ toast('Failed to load the WASM engine: ' + e.message);
+ engineReadyResolve(false);
+ return;
+ }
+ engineReadyResolve(true);
+ wireUI();
+ const params = new URLSearchParams(location.search);
+ const trace = params.get('trace')
+ ? (m) => {
+ document.title = 'TRACE ' + m;
+ }
+ : () => {};
+ trace('wired');
+ const demo = params.get('demo');
+ if (demo) {
+ try {
+ trace('fetching ' + demo);
+ const bytes = new Uint8Array(await (await fetch(demo)).arrayBuffer());
+ trace('fetched ' + bytes.length);
+ const file = new File([bytes], demo.split('/').pop() || 'demo.pdf', {
+ type: 'application/pdf',
+ });
+ await openFile(file, demo, bytes);
+ trace('opened');
+ } catch (e) {
+ trace('openfail ' + e.message);
+ toast('Demo load failed: ' + e.message);
+ }
+ }
+ const wantPage = parseInt(params.get('page') || '', 10);
+ if (demo && wantPage >= 1) {
+ try {
+ goToPage(wantPage - 1);
+ } catch {}
+ }
+ if (params.get('e2e') && demo) runE2E();
+ if (params.get('undotrace')) window.__undoTrace = true;
+ if (params.get('repro') && demo) setTimeout(runRepro, 300);
+ const eo = params.get('editopen');
+ const typein = params.get('typein');
+ const pg = parseInt(params.get('pg') || '0', 10);
+ if (pg > 1 && demo)
+ setTimeout(() => {
+ goToPage(pg - 1);
+ }, 200);
+ if (eo && demo)
+ setTimeout(() => {
+ setTool('edit');
+ const eds = state.paragraphs.filter((q) => q.editable);
+ const p =
+ eo === '#L'
+ ? eds
+ .slice()
+ .sort(
+ (a, b) =>
+ b.runs.map((r) => r.text).join('').length -
+ a.runs.map((r) => r.text).join('').length
+ )[0]
+ : eds.find((q) =>
+ q.runs
+ .map((r) => r.text)
+ .join('')
+ .includes(eo)
+ );
+ if (p) beginEdit(p);
+ if (p && typein)
+ setTimeout(() => {
+ const ed2 = state.editing?.editable;
+ if (!ed2) return;
+ ed2.focus();
+ const caretAt = params.get('caret');
+ if (caretAt != null && state.editing?.locked) {
+ lockedCaretSet(ed2, Math.max(0, parseInt(caretAt, 10) || 0));
+ }
+ for (const piece of typein.split(/(\n|\b)/)) {
+ if (piece === '\n') {
+ ed2.dispatchEvent(
+ new KeyboardEvent('keydown', {
+ key: 'Enter',
+ bubbles: true,
+ cancelable: true,
+ })
+ );
+ } else if (piece === '\b') {
+ const kd = new KeyboardEvent('keydown', {
+ key: 'Backspace',
+ bubbles: true,
+ cancelable: true,
+ });
+ ed2.dispatchEvent(kd);
+ if (!kd.defaultPrevented) document.execCommand('delete');
+ } else if (piece) {
+ document.execCommand('insertText', false, piece);
+ }
+ }
+ if (params.get('delcommit'))
+ setTimeout(() => {
+ const eng = P();
+ const orig = eng.commitParagraph.bind(eng);
+ let reply = null;
+ eng.commitParagraph = (...a) => {
+ reply = orig(...a);
+ return reply;
+ };
+ endEdit(true);
+ eng.commitParagraph = orig;
+ const hx = (t) =>
+ [...t].map((c) => c.codePointAt(0).toString(16)).join(' ');
+ const d = document.createElement('pre');
+ d.id = 'devout';
+ d.textContent =
+ 'surgical=' +
+ !!(reply && reply.surgical) +
+ ' reply=' +
+ (reply ? 'ok' : 'null') +
+ '\n' +
+ state.paragraphs
+ .map((q) => hx(q.runs.map((r) => r.text).join('')))
+ .join('\n');
+ document.body.appendChild(d);
+ }, 900);
+ if (params.get('probeshift')) {
+ const samples = [];
+ const t0 = performance.now();
+ const tick = () => {
+ const ed3 = state.editing?.editable;
+ if (ed3) {
+ const r = document.createRange();
+ const tw = document.createTreeWalker(ed3, NodeFilter.SHOW_TEXT);
+ let first = null;
+ while (tw.nextNode()) {
+ const tx = tw.currentNode;
+ if (tx.data && tx.data.replace(//g, '').trim()) {
+ first = tx;
+ break;
+ }
+ }
+ if (first) {
+ r.selectNodeContents(first);
+ const rect = r.getBoundingClientRect();
+ const wrap = state.editing.el.getBoundingClientRect();
+ samples.push(
+ `${Math.round(performance.now() - t0)}ms glyph=${rect.left.toFixed(1)},${rect.top.toFixed(1)} wrap=${wrap.left.toFixed(1)},${wrap.top.toFixed(1)}`
+ );
+ }
+ }
+ if (performance.now() - t0 < 1600) setTimeout(tick, 120);
+ else {
+ const d = document.createElement('pre');
+ d.id = 'shiftprobe';
+ d.textContent = samples.join('\n');
+ document.body.appendChild(d);
+ }
+ };
+ tick();
+ }
+ }, 600);
+ }, 300);
+ const ue = params.get('undoshot');
+ if (ue && demo)
+ setTimeout(() => {
+ if (ue === 'before') return;
+ const p = state.paragraphs.find(
+ (q) =>
+ q.editable &&
+ !q.rotation &&
+ q.runs.map((r) => r.text).join('').length > 20
+ );
+ if (!p) return;
+ const runs = p.runs.map((r, i) => ({
+ ...r,
+ rgba: r.rgba >>> 0,
+ sourceIndex: i,
+ }));
+ runs[runs.length - 1] = {
+ ...runs[runs.length - 1],
+ text: runs[runs.length - 1].text + ' q1',
+ };
+ snapshotEdit('edit text', p);
+ const u = P().commitParagraph(p.id, runs, p.format);
+ if (u) replaceParagraph(p.id, u);
+ refreshAfterMutation();
+ setTimeout(() => {
+ restore(state.undo, state.redo);
+ renderPage();
+ }, 300);
+ }, 700);
+ if (params.get('spell') && demo)
+ setTimeout(() => {
+ spellStart();
+ }, 700);
+ const tsel = params.get('textsel');
+ if (tsel && demo)
+ setTimeout(() => {
+ const H = P().pageHeight,
+ W = P().pageWidth;
+ const sel = P().selectText(
+ 0,
+ H * 0.98,
+ W,
+ H * 0.55,
+ tsel === 'rect' ? 1 : 0
+ );
+ showTextSelection(sel);
+ }, 600);
+ const lrep = params.get('lines');
+ if (lrep && demo)
+ setTimeout(async () => {
+ setTool('edit');
+ const p = state.paragraphs.find(
+ (q) =>
+ q.editable &&
+ q.runs
+ .map((r) => r.text)
+ .join('')
+ .includes(lrep)
+ );
+ const out = [];
+ if (!p) out.push('no paragraph matching ' + JSON.stringify(lrep));
+ else {
+ beginEdit(p);
+ try {
+ await document.fonts.ready;
+ } catch {}
+ await new Promise((r) => setTimeout(r, 400));
+ const es = state.editing,
+ pv = es.preview;
+ out.push(
+ `pinned=${pv?.pinned} lines: page ${p.lines.length} preview ${pv?.lines.length}`
+ );
+ {
+ const fb = [...es.editable.querySelectorAll('span[data-fitw]')];
+ const scaled = fb.filter((b) =>
+ (b.firstElementChild?.style.transform || '').includes('scaleX')
+ );
+ const tracked = fb.filter(
+ (b) => (b.firstElementChild?.style.letterSpacing || '') !== ''
+ );
+ const ks = scaled
+ .slice(0, 6)
+ .map((b) => b.firstElementChild.style.transform);
+ out.push(
+ ` fit boxes: ${fb.length}, scaled ${scaled.length}, tracked ${tracked.length} ${JSON.stringify(ks)}`
+ );
+ }
+ p.runs.slice(0, 4).forEach((r, i) => {
+ let synth = '?';
+ try {
+ const b = P().synthRunFont(p.id, i);
+ synth =
+ b === 'dishonest'
+ ? 'dishonest'
+ : b
+ ? b.length + ' bytes'
+ : 'null';
+ } catch (e) {
+ synth = 'throw ' + e.message;
+ }
+ const emb = (() => {
+ try {
+ const b = P().runFontData(p.id, i);
+ return b
+ ? b.length + (isLoadableSfnt(b) ? ' sfnt' : ' raw')
+ : 'none';
+ } catch {
+ return '?';
+ }
+ })();
+ out.push(
+ ` run ${i} "${(r.text || '').slice(0, 14)}" family=${r.family} synth=${synth} embedded=${emb} loaded=${docFontsReady.get(docFontKey(r)) || '-'}`
+ );
+ });
+ const els = [...es.editable.querySelectorAll('.eline')];
+ const pageR = $('page').getBoundingClientRect();
+ {
+ const cv = $('page'),
+ ctx = cv.getContext('2d');
+ const dpr = devicePixelRatio || 1;
+ const bx = Math.max(0, Math.floor(p.box.x * state.zoom * dpr));
+ const by = Math.max(
+ 0,
+ Math.floor((P().pageHeight - p.box.top) * state.zoom * dpr)
+ );
+ const bw = Math.min(
+ cv.width - bx,
+ Math.ceil(p.box.w * state.zoom * dpr)
+ );
+ const bh = Math.min(
+ cv.height - by,
+ Math.ceil(p.box.h * state.zoom * dpr)
+ );
+ let ink = 0;
+ if (bw > 0 && bh > 0) {
+ const d = ctx.getImageData(bx, by, bw, bh).data;
+ for (let q = 0; q < d.length; q += 4) if (d[q + 1] < 200) ink++;
+ }
+ out.push(`canvas ink under the open editor: ${ink}px (want ~0)`);
+ }
+ els.forEach((d, k) => {
+ const sp = [...d.querySelectorAll('span')].find((q) =>
+ q.textContent.trim()
+ );
+ let got = NaN;
+ if (sp) {
+ const r = sp.getBoundingClientRect();
+ const cs = getComputedStyle(sp);
+ const F = parseFloat(cs.fontSize);
+ const ad = fontAD(
+ cs.fontFamily,
+ parseInt(cs.fontWeight) >= 600,
+ cs.fontStyle.includes('italic')
+ );
+ got = P().pageHeight - (r.top + ad.a * F - pageR.top) / state.zoom;
+ }
+ out.push(
+ ` L${k} page ${p.lines[k]?.y?.toFixed(2)} preview ${pv?.lines[k]?.baseline?.toFixed(2)}` +
+ ` rendered ${isNaN(got) ? '-' : got.toFixed(2)} exact=${d.dataset.exact} start=${JSON.stringify((d.textContent || '').slice(0, 6))} end=${JSON.stringify((d.textContent || '').slice(-4))}`
+ );
+ });
+ {
+ const M = P().M;
+ const ptr = M._ec_test_pagetext(P().session, P().page);
+ const chars = JSON.parse(M.UTF8ToString(ptr));
+ M._ec_string_free(ptr);
+ const lineTxt = (els[0]?.textContent || '').replace(/\u200B/g, '');
+ const vis = [];
+ for (let i = 0; i < lineTxt.length && vis.length < 18; i++)
+ if (!/\s/.test(lineTxt[i])) vis.push([lineTxt[i], i]);
+ const codes = vis.map(([c]) => c.codePointAt(0));
+ const pageVis = [];
+ for (const c of chars)
+ if (!/\s/.test(String.fromCodePoint(c[0]))) pageVis.push(c);
+ let at = -1;
+ outer: for (let i = 0; i + codes.length <= pageVis.length; i++) {
+ for (let j = 0; j < codes.length; j++)
+ if (pageVis[i + j][0] !== codes[j]) continue outer;
+ at = i;
+ break;
+ }
+ const pageR3 = $('page').getBoundingClientRect();
+ const walker = document.createTreeWalker(
+ els[0],
+ NodeFilter.SHOW_TEXT
+ );
+ const nodes = [];
+ for (let nnn = walker.nextNode(); nnn; nnn = walker.nextNode())
+ nodes.push(nnn);
+ let flat = '',
+ map = [];
+ for (const nd of nodes)
+ for (let k = 0; k < nd.textContent.length; k++) {
+ flat += nd.textContent[k];
+ map.push([nd, k]);
+ }
+ const domVis = [];
+ for (let i = 0; i < flat.length; i++)
+ if (!/\s/.test(flat[i]) && !/[\u200B]/.test(flat[i]))
+ domVis.push(i);
+ let worst = 0,
+ worstAt = '';
+ if (at >= 0 && domVis.length >= codes.length) {
+ for (let j = 0; j < codes.length; j++) {
+ const [nd, k] = map[domVis[j]] || [];
+ if (!nd) continue;
+ const rg = document.createRange();
+ rg.setStart(nd, k);
+ rg.setEnd(nd, k + 1);
+ const rr = rg.getBoundingClientRect();
+ if (!rr.width && !rr.height) continue;
+ const got = (rr.left - pageR3.left) / state.zoom;
+ const want = pageVis[at + j][2] / 10;
+ if (Math.abs(got - want) > worst) {
+ worst = Math.abs(got - want);
+ worstAt = String.fromCodePoint(codes[j]) + '@' + j;
+ }
+ if (j < 20)
+ out.push(
+ ` ch ${JSON.stringify(String.fromCodePoint(codes[j]))} page ${want.toFixed(2)} overlay ${got.toFixed(2)} d=${(got - want).toFixed(2)}`
+ );
+ }
+ }
+ out.push(
+ `per-glyph drift vs the page: worst ${worst.toFixed(2)}pt (${worstAt || 'n/a'}) over ${codes.length} chars` +
+ ` [pageIdx=${at} probe=${JSON.stringify(vis.map(([c]) => c).join(''))}]`
+ );
+ {
+ const jumps = [];
+ for (const ln2 of els) {
+ const walk2 = document.createTreeWalker(
+ ln2,
+ NodeFilter.SHOW_TEXT
+ );
+ let flat2 = '';
+ const map2 = [];
+ for (let n2 = walk2.nextNode(); n2; n2 = walk2.nextNode())
+ for (let k2 = 0; k2 < n2.textContent.length; k2++) {
+ flat2 += n2.textContent[k2];
+ map2.push([n2, k2]);
+ }
+ const vis2 = [];
+ for (let q = 0; q < flat2.length; q++)
+ if (!/[\s\u200B\uFFFC]/.test(flat2[q])) vis2.push(q);
+ if (vis2.length < 8) continue;
+ const codes2 = vis2.map((q) => flat2.codePointAt(q));
+ const rectOf2 = (q) => {
+ const [nd2, k3] = map2[q];
+ const rg2 = document.createRange();
+ rg2.setStart(nd2, k3);
+ rg2.setEnd(nd2, k3 + 1);
+ const r2 = rg2.getBoundingClientRect();
+ return r2.width || r2.height
+ ? (r2.left - pageR3.left) / state.zoom
+ : null;
+ };
+ const x02 = rectOf2(vis2[0]);
+ let at2 = -1,
+ best2 = Infinity;
+ outer2: for (
+ let q = 0;
+ q + codes2.length <= pageVis.length;
+ q++
+ ) {
+ for (let z2 = 0; z2 < codes2.length; z2++)
+ if (pageVis[q + z2][0] !== codes2[z2]) continue outer2;
+ const dd = Math.abs(pageVis[q][2] / 10 - x02);
+ if (dd < best2) {
+ best2 = dd;
+ at2 = q;
+ }
+ }
+ if (at2 < 0 || best2 > 3) continue;
+ let prevD = 0;
+ for (let z2 = 0; z2 < codes2.length; z2++) {
+ const g2 = rectOf2(vis2[z2]);
+ if (g2 === null) continue;
+ const d2 = g2 - pageVis[at2 + z2][2] / 10;
+ if (Math.abs(d2 - prevD) > 0.8 && jumps.length < 8)
+ jumps.push(
+ `${JSON.stringify(String.fromCodePoint(codes2[z2]))} d=${d2.toFixed(2)} (${d2 - prevD >= 0 ? '+' : ''}${(d2 - prevD).toFixed(2)}) after ${JSON.stringify(flat2.slice(Math.max(0, vis2[z2] - 10), vis2[z2]))}`
+ );
+ prevD = d2;
+ }
+ }
+ if (jumps.length) out.push(' drift jumps: ' + jumps.join(' | '));
+ }
+ }
+ {
+ const d0 = els[0];
+ const pageR2 = $('page').getBoundingClientRect();
+ [...(d0?.children || [])].slice(0, 8).forEach((box, bi) => {
+ const br = box.getBoundingClientRect();
+ const inner = box.querySelector('span');
+ const ir = inner?.getBoundingClientRect();
+ const cs2 = inner ? getComputedStyle(inner) : null;
+ out.push(
+ ` box${bi} x=${((br.left - pageR2.left) / state.zoom).toFixed(2)}` +
+ ` w=${(br.width / state.zoom).toFixed(2)} ink=${ir ? (ir.width / state.zoom).toFixed(2) : '-'}` +
+ ` font=${cs2 ? cs2.fontSize + ' ' + cs2.fontFamily.slice(0, 24) : '-'}` +
+ ` ${JSON.stringify((box.textContent || '').slice(0, 12))}`
+ );
+ if (bi === 6)
+ out.push(' box6 html: ' + box.outerHTML.slice(0, 400));
+ });
+ }
+ endEdit(false);
+ }
+ const pre = document.createElement('pre');
+ pre.id = 'diagout';
+ pre.textContent = out.join('\n');
+ document.body.appendChild(pre);
+ document.title = 'DIAGDONE';
+ }, 500);
+ const dg = params.get('diag');
+ if (dg && demo)
+ setTimeout(() => {
+ const out = [];
+ setTool('edit');
+ const p = state.paragraphs.find(
+ (q) =>
+ q.editable &&
+ q.runs
+ .map((r) => r.text)
+ .join('')
+ .includes(dg)
+ );
+ out.push(
+ 'model text: ' + JSON.stringify(p.runs.map((r) => r.text).join(''))
+ );
+ beginEdit(p);
+ const ed = state.editing.editable;
+ ed.focus();
+ const r0 = ed.getBoundingClientRect();
+ ed.dispatchEvent(
+ new MouseEvent('mousedown', {
+ bubbles: true,
+ clientX: r0.left + 20,
+ clientY: r0.top + 20,
+ })
+ );
+ ed.dispatchEvent(
+ new MouseEvent('mouseup', {
+ bubbles: true,
+ clientX: r0.left + 20,
+ clientY: r0.top + 20,
+ })
+ );
+ out.push('editor innerHTML: ' + ed.innerHTML.slice(0, 200));
+ out.push(
+ 'editor textContent: ' + JSON.stringify(ed.textContent.slice(0, 80))
+ );
+ const parsed = parseEditor(ed, p.runs);
+ out.push(
+ 'parseEditor text: ' +
+ JSON.stringify(
+ parsed
+ .map((r) => r.text)
+ .join('')
+ .slice(0, 80)
+ )
+ );
+ for (let i = 0; i < Math.max(parsed.length, p.runs.length); i++) {
+ const a = p.runs[i]?.text ?? '
',
+ b = parsed[i]?.text ?? '';
+ if (a === b) continue;
+ out.push(' run ' + i + ' model ' + JSON.stringify(a.slice(0, 90)));
+ out.push(' run ' + i + ' parsed ' + JSON.stringify(b.slice(0, 90)));
+ }
+ out.push(
+ 'run counts: model ' + p.runs.length + ' parsed ' + parsed.length
+ );
+ {
+ const A = p.runs.map((r) => r.text).join(''),
+ B = parsed.map((r) => r.text).join('');
+ let i = 0;
+ while (i < A.length && i < B.length && A[i] === B[i]) i++;
+ const cps = (t) =>
+ [...t]
+ .map(
+ (c) =>
+ 'U+' +
+ c.codePointAt(0).toString(16).toUpperCase().padStart(4, '0')
+ )
+ .join(' ');
+ out.push(
+ 'text lengths: model ' +
+ A.length +
+ ' parsed ' +
+ B.length +
+ ', first difference at ' +
+ i
+ );
+ out.push(' model ' + cps(A.slice(Math.max(0, i - 6), i + 8)));
+ out.push(' parsed ' + cps(B.slice(Math.max(0, i - 6), i + 8)));
+ [...ed.querySelectorAll('.eline')].forEach((d, k) => {
+ const t = d.textContent;
+ if (!t.includes('\uFFFC')) return;
+ out.push(
+ ' line ' +
+ k +
+ ' dom ' +
+ cps(t.slice(0, 12)) +
+ ' html ' +
+ d.innerHTML.slice(0, 260)
+ );
+ });
+ }
+ endEdit(true);
+ const after = state.paragraphs.find(
+ (q) =>
+ q.runs
+ .map((r) => r.text)
+ .join('')
+ .includes('REPORT') ||
+ q.runs
+ .map((r) => r.text)
+ .join('')
+ .includes(dg.slice(0, 4))
+ );
+ out.push(
+ 'committed text: ' +
+ JSON.stringify(
+ (after?.runs.map((r) => r.text).join('') || '').slice(0, 80)
+ ) +
+ ' lines=' +
+ after?.lines.length
+ );
+ const pre = document.createElement('pre');
+ pre.id = 'diagout';
+ pre.textContent = out.join('\n');
+ pre.style.cssText =
+ 'position:fixed;left:0;bottom:0;z-index:99;color:#0f0;background:#000;font:12px monospace;padding:6px;white-space:pre-wrap;max-width:100vw';
+ document.body.appendChild(pre);
+ document.title = 'DIAGDONE';
+ }, 400);
+ const et = params.get('edittext');
+ if (et && demo) {
+ const [find, repl] = et.split('|');
+ const target = state.paragraphs.find(
+ (p) =>
+ p.editable &&
+ p.runs
+ .map((r) => r.text)
+ .join('')
+ .includes(find)
+ );
+ if (target) {
+ const runs = target.runs.map((r, i) => ({
+ ...r,
+ rgba: r.rgba >>> 0,
+ text: r.text.split(find).join(repl),
+ sourceIndex: i,
+ }));
+ const u = P().commitParagraph(target.id, runs, target.format);
+ if (u) {
+ replaceParagraph(target.id, u);
+ state.dirty = true;
+ renderPage();
+ }
+ }
+ }
+})();
+
+async function runRepro() {
+ const log = [];
+ const stopAfter = parseInt(
+ new URLSearchParams(location.search).get('stop') || '0',
+ 10
+ );
+ const say = (m) => {
+ log.push(m);
+ if (stopAfter && log.length >= stopAfter) throw { stopNow: true };
+ };
+ const canvas = $('page');
+ const inkOf = () => {
+ const d = canvas
+ .getContext('2d')
+ .getImageData(0, 0, canvas.width, canvas.height).data;
+ let n = 0;
+ for (let i = 0; i < d.length; i += 4) if (d[i + 1] < 200) n++;
+ return n;
+ };
+ window.__censusStart = (() => {
+ const rows = [];
+ for (let i = 0; i < P().objectCount(); i++) {
+ const o = P().objectAt(i);
+ if (!o) continue;
+ const b = P().objectBounds(o.handle);
+ rows.push(
+ `${o.type}@${b ? b.x.toFixed(1) + ',' + b.y.toFixed(1) + ' ' + b.w.toFixed(1) + 'x' + b.h.toFixed(1) : '?'}`
+ );
+ }
+ return rows.sort();
+ })();
+ const undoBase = state.undo.length;
+ const ink00 = inkOf();
+ window.__inkVirgin = ink00;
+
+ const reproDoubleEdit = async () => {
+ const out = [];
+ for (let n = 1; n <= 2; n++) {
+ const p = state.paragraphs.find(
+ (q) =>
+ q.editable &&
+ !q.rotation &&
+ q.runs.map((r) => r.text).join('').length > 20
+ );
+ if (!p) {
+ out.push('no paragraph');
+ break;
+ }
+ const runs = p.runs.map((r, i) => ({ ...r, sourceIndex: i }));
+ runs[runs.length - 1] = {
+ ...runs[runs.length - 1],
+ text: runs[runs.length - 1].text + ' q' + n,
+ };
+ snapshotEdit('edit text', p);
+ const u = P().commitParagraph(p.id, runs, p.format);
+ if (u) replaceParagraph(p.id, u);
+ refreshAfterMutation();
+ await new Promise((r) => setTimeout(r, 400));
+ renderPage();
+ out.push('edit' + n + ' ink ' + inkOf());
+ }
+ let guard = 0;
+ while (state.undo.length > undoBase && guard++ < 10)
+ restore(state.undo, state.redo);
+ refreshModel();
+ renderPage();
+ return 'double edit: ' + out.join(' | ') + ' (want both ~equal)';
+ };
+ const reproTextSelection = () => {
+ endEdit(false);
+ const H = P().pageHeight,
+ W = P().pageWidth;
+ const sel = P().selectText(0, H * 0.92, W, H * 0.45, 0);
+ if (!sel) return 'page text selection: engine returned nothing (want text)';
+ showTextSelection(sel);
+ const drawn = document.querySelectorAll('.textsel').length;
+ const marquee = P().selectText(W * 0.1, H * 0.5, W * 0.9, H * 0.9, 1);
+ clearTextSelection();
+ const gone = document.querySelectorAll('.textsel').length;
+ return (
+ 'page text selection: ' +
+ sel.blocks.length +
+ ' blocks / ' +
+ sel.quads.length +
+ ' quads drawn ' +
+ drawn +
+ ', marquee ' +
+ (marquee ? marquee.blocks.length : 0) +
+ ' blocks, cleared ' +
+ (gone === 0) +
+ ' (want >1 blocks, quads==drawn, cleared true)'
+ );
+ };
+ const reproSpelling = async () => {
+ endEdit(false);
+ const ok = await spellDictionary();
+ if (!ok) return 'spelling: no dictionary (skipped)';
+ const p = state.paragraphs.find(
+ (q) =>
+ q.editable &&
+ q.runs
+ .map((r) => r.text)
+ .join('')
+ .split(/\s+/).length > 4
+ );
+ if (!p) return 'spelling: no paragraph to test';
+ const runs = p.runs.map((r, i) => ({
+ ...r,
+ rgba: r.rgba >>> 0,
+ sourceIndex: i,
+ }));
+ runs[0] = { ...runs[0], text: 'definitly ' + runs[0].text };
+ snapshotEdit('edit text', p);
+ const u = P().commitParagraph(p.id, runs, p.format);
+ if (u) replaceParagraph(p.id, u);
+ refreshAfterMutation();
+ spellRescan();
+ const found = spell.list.some((w) => w.word.toLowerCase() === 'definitly');
+ const before = spell.list.length;
+ spell.at = spell.list.findIndex(
+ (w) => w.word.toLowerCase() === 'definitly'
+ );
+ if (spell.at >= 0) {
+ $('spellFix').value = 'definitely';
+ spellChange();
+ }
+ const fixed = !spell.list.some((w) => w.word.toLowerCase() === 'definitly');
+ const text = state.paragraphs
+ .map((q) => q.runs.map((r) => r.text).join(''))
+ .join(' ');
+ restore(state.undo, state.redo);
+ restore(state.undo, state.redo);
+ return (
+ 'spelling: ' +
+ ' found=' +
+ found +
+ ' (' +
+ before +
+ ' flagged) fixed=' +
+ fixed +
+ ' corrected=' +
+ text.includes('definitely') +
+ ' (want true true true)'
+ );
+ };
+ const reproUndoParity = () => {
+ endEdit(false);
+ const census = () => {
+ const rows = [];
+ for (let i = 0; i < P().objectCount(); i++) {
+ const o = P().objectAt(i);
+ if (!o) continue;
+ const b = P().objectBounds(o.handle);
+ rows.push(
+ `${o.type}@${b ? b.x.toFixed(1) + ',' + b.y.toFixed(1) + ' ' + b.w.toFixed(1) + 'x' + b.h.toFixed(1) : '?'}`
+ );
+ }
+ return rows.sort();
+ };
+ const censusStart = window.__censusStart || [];
+ let guard = 0;
+ const trace = [];
+ while (state.undo.length > undoBase && guard++ < 40) {
+ const top = state.undo[state.undo.length - 1];
+ const kind = top?.engine
+ ? (top.label || 'engine') + (top.what ? '[' + top.what + ']' : '')
+ : 'bytes';
+ restore(state.undo, state.redo);
+ renderPage();
+ {
+ const now = census();
+ const extra = now.filter((r) => !censusStart.includes(r)).length;
+ const miss = censusStart.filter((r) => !now.includes(r)).length;
+ trace.push(`${kind}:${inkOf()}(+${extra}/-${miss})`);
+ }
+ }
+ if (window.__undoTrace) say(' undo trace: ' + trace.join(' '));
+ if (window.__undoTrace && window.__ledger)
+ say(' ledger: ' + window.__ledger.join(' | '));
+ {
+ const now = census();
+ const missing = censusStart.filter((r) => !now.includes(r));
+ const extra = now.filter((r) => !censusStart.includes(r));
+ if (censusStart.length && (missing.length || extra.length))
+ (say(
+ ' displaced: missing ' +
+ JSON.stringify(missing.slice(0, 3)) +
+ ' extra ' +
+ JSON.stringify(extra.slice(0, 3))
+ ),
+ say(
+ ' note: ' +
+ missing.length +
+ '/' +
+ extra.length +
+ ' objects re-emitted rather than restored verbatim (same ink; an ' +
+ 'undo may rebuild a paragraph instead of putting its old objects ' +
+ 'back)'
+ ));
+ }
+ renderPage();
+ const inkZ = inkOf();
+ const par = Math.min(ink00, inkZ) / Math.max(1, Math.max(ink00, inkZ));
+ const refs = [ink00, window.__inkVirgin, window.__inkNormalized].filter(
+ (v) => v !== undefined
+ );
+ const best = Math.max(
+ ...refs.map((v) => Math.min(v, inkZ) / Math.max(1, Math.max(v, inkZ)))
+ );
+ return (
+ 'undo parity: ink ' +
+ ink00 +
+ ' → ' +
+ inkZ +
+ (best > 0.995 ? ' (clean)' : ' GARBLED (' + (par * 100).toFixed(1) + '%)')
+ );
+ };
+ const rect = canvas.getBoundingClientRect();
+ const z = state.zoom;
+ const pageToClient = (px, py) => ({
+ x: rect.left + px * z,
+ y: rect.top + (P().pageHeight - py) * z,
+ });
+ const fire = (el, type, cx, cy, detail = 1) =>
+ el.dispatchEvent(
+ new MouseEvent(type, {
+ bubbles: true,
+ cancelable: true,
+ clientX: cx,
+ clientY: cy,
+ detail,
+ })
+ );
+
+ const reproObjectMove = (
+ wantType,
+ label,
+ minW,
+ minH,
+ maxW = 1e9,
+ maxH = 1e9
+ ) => {
+ let imgObj = null;
+ for (let i = 0; i < P().objectCount(); i++) {
+ const o = P().objectAt(i);
+ if (o && o.type === wantType) {
+ const b = P().objectBounds(o.handle);
+ if (b && b.w > minW && b.h > minH && b.w < maxW && b.h < maxH) {
+ imgObj = { ...o, bounds: b };
+ break;
+ }
+ }
+ }
+ if (!imgObj) return null;
+ endEdit(false);
+ selectObject(imgObj);
+ const inkAt = (b) => {
+ const cv = $('page');
+ const ctx = cv.getContext('2d');
+ const zz = state.zoom * devicePixelRatio;
+ const H = P().pageHeight;
+ const x0 = Math.max(0, Math.round(b.x * zz)),
+ y0 = Math.max(0, Math.round((H - b.y - b.h) * zz));
+ const w = Math.min(cv.width - x0, Math.round(b.w * zz)),
+ h = Math.min(cv.height - y0, Math.round(b.h * zz));
+ if (w <= 0 || h <= 0) return 0;
+ const d = ctx.getImageData(x0, y0, w, h).data;
+ let n = 0;
+ for (let k = 0; k < d.length; k += 4)
+ if (d[k] < 200 || d[k + 1] < 200 || d[k + 2] < 200) n++;
+ return n;
+ };
+ const before = inkAt(imgObj.bounds);
+ const box = $('overlay').querySelector('.obj-box');
+ if (!box) return label + ' move: obj-box missing';
+ {
+ const rb = box.getBoundingClientRect();
+ let threw = null;
+ const onErr = (ev) => {
+ threw = ev.message || String(ev.error);
+ };
+ window.addEventListener('error', onErr);
+ try {
+ box.dispatchEvent(
+ new MouseEvent('mousemove', {
+ bubbles: true,
+ clientX: rb.left + rb.width / 2,
+ clientY: rb.top + rb.height / 2,
+ })
+ );
+ } catch (err) {
+ threw = err.message;
+ }
+ window.removeEventListener('error', onErr);
+ if (threw) return label + ' move: hover threw ' + threw + ' (want none)';
+ if (!box.style.cursor)
+ return label + ' move: hover set no cursor (want move/text)';
+ }
+ const r = box.getBoundingClientRect();
+ fire(box, 'mousedown', r.left + r.width / 2, r.top + r.height / 2);
+ fire(
+ window,
+ 'mousemove',
+ r.left + r.width / 2 + 90,
+ r.top + r.height / 2 + 40
+ );
+ fire(
+ window,
+ 'mouseup',
+ r.left + r.width / 2 + 90,
+ r.top + r.height / 2 + 40
+ );
+ const nb = state.selection?.bounds || P().objectBounds(imgObj.handle);
+ const after = inkAt(nb);
+ return (
+ label +
+ ' move: ink ' +
+ before +
+ ' -> ' +
+ after +
+ ' (want similar, not ~0)'
+ );
+ };
+ const reproSynthFont = async () => {
+ for (const p of state.paragraphs) {
+ for (let i = 0; i < p.runs.length; i++) {
+ const d = P().runFontData(p.id, i);
+ if (!d) continue;
+ const synth = P().synthRunFont(p.id, i);
+ if (synth === 'dishonest') continue;
+ if (!synth) {
+ if (isLoadableSfnt(d)) continue;
+ return 'synth overlay font: engine returned null';
+ }
+ try {
+ const face = new FontFace('ecsynthrepro', synth.buffer);
+ await face.load();
+ document.fonts.add(face);
+ const cv = document.createElement('canvas');
+ const cx = cv.getContext('2d');
+ cx.font = '100px ecsynthrepro';
+ const wi = cx.measureText('iii').width,
+ wM = cx.measureText('MMM').width;
+ document.fonts.delete(face);
+ return (
+ 'synth overlay font: loaded, widths iii=' +
+ wi.toFixed(0) +
+ ' MMM=' +
+ wM.toFixed(0) +
+ (wM > wi && wi > 0 ? ' OK' : ' BAD')
+ );
+ } catch (e) {
+ return 'synth overlay font: FontFace REJECTED (' + e.message + ')';
+ }
+ }
+ }
+ return null;
+ };
+ const reproRotateChrome = () => {
+ const msg = [];
+ let obj = null;
+ for (let i = 0; i < P().objectCount(); i++) {
+ const o = P().objectAt(i);
+ if (o && o.type !== OBJ.TEXT) {
+ const b = P().objectBounds(o.handle);
+ if (b && b.w > 20 && b.h > 6) {
+ obj = { ...o, bounds: b };
+ break;
+ }
+ }
+ }
+ if (obj) {
+ endEdit(false);
+ selectObject(obj);
+ msg.push(
+ 'obj-click rotate: knob=' +
+ !!$('overlay').querySelector('.rot-handle') +
+ ' btn=' +
+ !$('rotL').hidden +
+ ' (want true true)'
+ );
+ }
+ const p = state.paragraphs.find((q) => q.editable && !q.rotation);
+ if (p) {
+ beginEdit(p);
+ msg.push(
+ 'edit-mode rotate knob=' +
+ !!$('overlay').querySelector('.edit-chrome .rot-handle') +
+ ' (want true)'
+ );
+ endEdit(false);
+ }
+ return msg.join('; ') || null;
+ };
+ const reproArabicCommit = () => {
+ const p = state.paragraphs.find((q) =>
+ /[\u0600-\u06FF]/.test(q.runs.map((r) => r.text).join(''))
+ );
+ if (!p) return null;
+ endEdit(false);
+ const runs = p.runs.map((r, i) => ({
+ ...r,
+ rgba: r.rgba >>> 0,
+ sourceIndex: i,
+ }));
+ runs[0] = { ...runs[0], text: runs[0].text + ' بت' };
+ const u = P().commitParagraph(p.id, runs, p.format);
+ if (!u) return 'arabic commit: FAILED';
+ refreshModel();
+ const t = state.paragraphs
+ .map((q) => q.runs.map((r) => r.text).join(''))
+ .join('\n');
+ const roundTrip = t.includes(' بت') || t.includes('بت ');
+ const garbage = /ÿ/.test(t);
+ return (
+ 'arabic commit: round-trip=' +
+ roundTrip +
+ ' garbage=' +
+ garbage +
+ ' (want true false)'
+ );
+ };
+ const reproRtlBackspace = () => {
+ const RTL = /[\u0590-\u05FF\u0600-\u06FF]/;
+ const p = state.paragraphs.find(
+ (q) =>
+ q.editable &&
+ !q.rotation &&
+ RTL.test(q.runs.map((r) => r.text).join(''))
+ );
+ if (!p) return null;
+ endEdit(false);
+ const before = p.runs.map((r) => r.text).join('');
+ if (before.length < 2) return null;
+ beginEdit(p);
+ const es = state.editing;
+ if (!es?.editable) return 'rtl backspace: no editor';
+ const domText = (es.editable.textContent || '').replace(/\u200B/g, '');
+ lockedCaretSet(es.editable, before.length);
+ document.execCommand('delete');
+ const domAfter = (es.editable.textContent || '').replace(/\u200B/g, '');
+ endEdit(true);
+ refreshModel();
+ const want = before.slice(0, -1);
+ const cand = state.paragraphs
+ .map((q) => q.runs.map((r) => r.text).join(''))
+ .filter((t) => RTL.test(t));
+ const after = cand.includes(want)
+ ? want
+ : (cand.find((t) => t !== before && t.length === want.length) ??
+ '(no match)');
+ let out =
+ 'rtl backspace: ' +
+ JSON.stringify(before) +
+ ' -> ' +
+ JSON.stringify(after);
+ out +=
+ after === want
+ ? ' (last logical char, OK)'
+ : ' MISMATCH want ' +
+ JSON.stringify(want) +
+ ' | dom ' +
+ JSON.stringify(domText) +
+ ' -> ' +
+ JSON.stringify(domAfter);
+ return out;
+ };
+ const reproRtlArrows = () => {
+ const RTL = /[\u0590-\u05FF\u0600-\u06FF]/;
+ const p = state.paragraphs.find(
+ (q) =>
+ q.editable &&
+ !q.rotation &&
+ RTL.test(q.runs.map((r) => r.text).join(''))
+ );
+ if (!p) return null;
+ endEdit(false);
+ beginEdit(p);
+ const es = state.editing;
+ if (!es?.editable || !es.locked) {
+ endEdit(false);
+ return 'rtl arrows: no locked editor';
+ }
+ const ed = es.editable;
+ const key = (k) =>
+ ed.dispatchEvent(
+ new KeyboardEvent('keydown', {
+ key: k,
+ bubbles: true,
+ cancelable: true,
+ })
+ );
+ lockedCaretSet(ed, 5);
+ key('ArrowRight');
+ const afterRight = lockedCaretGet(ed);
+ lockedCaretSet(ed, 5);
+ key('ArrowLeft');
+ const afterLeft = lockedCaretGet(ed);
+ endEdit(false);
+ const ok = afterRight === 6 && afterLeft === 4;
+ return (
+ 'rtl arrows: from 5 -> Right ' +
+ afterRight +
+ ', Left ' +
+ afterLeft +
+ (ok ? ' (logical, OK)' : ' MISMATCH want Right 6, Left 4')
+ );
+ };
+ const reproFindReplace = () => {
+ const msg = [];
+ const opts = { cs: false, word: false, noAcc: false };
+ let needle = null,
+ total = 0;
+ for (const q of state.paragraphs) {
+ if (!q.editable) continue;
+ for (const w of paraText(q).match(/[A-Za-z\u0600-\u06FF]{3,}/g) || []) {
+ let n = 0;
+ for (const r of state.paragraphs)
+ if (r.editable) n += paraMatches(r, w, opts).length;
+ if (n >= 2) {
+ needle = w;
+ total = n;
+ break;
+ }
+ }
+ if (needle) break;
+ }
+ if (needle) {
+ state.find = null;
+ const a = findStep(needle, 1),
+ b = findStep(needle, 1);
+ const advanced =
+ !!a && !!b && !(a.paraId === b.paraId && a.start === b.start);
+ const c = findStep(needle, -1);
+ const back = !!c && !!a && c.paraId === a.paraId && c.start === a.start;
+ msg.push(
+ `find "${needle}": ${total} matches, next advances=${advanced}, ` +
+ `prev returns=${back}` +
+ (advanced && back ? '' : ' MISMATCH')
+ );
+ state.find = null;
+ clearTextSelection();
+ }
+ const fake = {
+ runs: [
+ { text: 'abcd', rgba: 0 },
+ { text: 'efgh', rgba: 0 },
+ { text: 'ijkl', rgba: 0 },
+ ],
+ };
+ const spans = paraMatches(fake, 'defghi', opts);
+ const rebuilt =
+ spans.length === 1
+ ? runsWithReplacements(fake, spans, '@')
+ .map((r) => r.text)
+ .join('')
+ : '(no match)';
+ const okX = spans.length === 1 && rebuilt === 'abc@jkl';
+ msg.push(
+ `cross-run replace: "abcd|efgh|ijkl" - "defghi" => "${rebuilt}"` +
+ (okX ? ' (OK)' : ' MISMATCH want abc@jkl')
+ );
+ return msg.join('; ');
+ };
+ const reproBlockSelect = () => {
+ const rows = state.paragraphs.filter((q) => q.blockId);
+ if (rows.length < 2) return null;
+ const id = rows[0].blockId;
+ const group = rows.filter((q) => q.blockId === id);
+ endEdit(false);
+ state.selection = { kind: 'para', para: group[0] };
+ drawOverlay();
+ const sel = $('overlay').querySelector('.sel-para');
+ if (!sel) return 'block select: no selection box MISMATCH';
+ let top = 1e9,
+ bot = -1e9;
+ for (const q of group) {
+ const pl = paraPlacement(q);
+ top = Math.min(top, pl.y);
+ bot = Math.max(bot, pl.y + pl.h);
+ }
+ const chrome = sel.parentElement;
+ const y = parseFloat(chrome.style.top) + parseFloat(sel.style.top);
+ const h = parseFloat(sel.style.height);
+ const wantH = bot - top + 2 * BOX_PAD;
+ const ok = Math.abs(h - wantH) < 2.5;
+ state.selection = null;
+ drawOverlay();
+ return (
+ `block select: ${group.length} rows, outline h=${h.toFixed(0)} ` +
+ `want ${wantH.toFixed(0)}` +
+ (ok ? ' (one box, OK)' : ' MISMATCH')
+ );
+ };
+ const reproBlockEdit = async () => {
+ const rows = state.paragraphs.filter((q) => q.blockId);
+ if (rows.length < 2) return null;
+ const bid = rows[0].blockId;
+ const group = rows.filter((q) => q.blockId === bid);
+ endEdit(false);
+ setTool('edit');
+ beginEdit(group[Math.floor(group.length / 2)]);
+ await new Promise((r) => setTimeout(r, 40));
+ const ov = $('overlay');
+ const blockEl = ov.querySelector('.block-box.active');
+ if (!blockEl)
+ return "block edit: open row's block has no active box MISMATCH";
+ const br = blockEl.getBoundingClientRect();
+ const msg = [];
+ const lit = (w, style, color) =>
+ parseFloat(w) > 0 &&
+ style !== 'none' &&
+ !/,\s*0\)$/.test(color) &&
+ color !== 'transparent';
+ let extra = 0;
+ for (const el of ov.querySelectorAll(
+ '.para-box, .sel-para, [contenteditable]'
+ )) {
+ if (el === blockEl) continue;
+ const cs = getComputedStyle(el);
+ if (
+ !lit(cs.borderTopWidth, cs.borderTopStyle, cs.borderTopColor) &&
+ !lit(cs.outlineWidth, cs.outlineStyle, cs.outlineColor)
+ )
+ continue;
+ const r = el.getBoundingClientRect();
+ if (
+ r.left >= br.left - 2 &&
+ r.right <= br.right + 2 &&
+ r.top >= br.top - 2 &&
+ r.bottom <= br.bottom + 2
+ )
+ extra++;
+ }
+ msg.push(
+ `block edit: ${group.length} rows, boxes nested in the outline=` +
+ extra +
+ (extra === 0 ? ' (one box, OK)' : ' MISMATCH')
+ );
+ const strip = ov.querySelector('.edit-move[data-edge="n"]');
+ msg.push('move strip on the block box: ' + !!strip);
+ if (strip) {
+ const topOf = () =>
+ group.map(
+ (q) => state.paragraphs.find((p) => p.id === q.id)?.box.top ?? 0
+ );
+ const geo = () =>
+ state.paragraphs
+ .map((p) => p.box.x.toFixed(2) + ',' + p.box.top.toFixed(2))
+ .sort()
+ .join('|');
+ const before = topOf(),
+ geo0 = geo(),
+ undo0 = state.undo.length;
+ const sr = strip.getBoundingClientRect();
+ const cx = sr.left + sr.width / 2,
+ cy = sr.top + sr.height / 2;
+ fire(strip, 'mousedown', cx, cy);
+ fire(window, 'mousemove', cx, cy + 30);
+ fire(window, 'mouseup', cx, cy + 30);
+ const after = topOf();
+ const d0 = after[0] - before[0];
+ const together = after.every(
+ (t, i) => Math.abs(t - before[i] - d0) < 0.01
+ );
+ msg.push(
+ `block move: ${group.length} rows shifted ${d0.toFixed(1)}pt` +
+ (Math.abs(d0) > 1 && together ? ' (whole block, OK)' : ' MISMATCH')
+ );
+ const steps = state.undo.length - undo0;
+ endEdit(false);
+ if (steps) {
+ restore(state.undo, state.redo);
+ renderPage();
+ }
+ const back = geo() === geo0;
+ msg.push(
+ `block move undo: ${steps} step, page geometry back=${back}` +
+ (steps === 1 && back ? ' (OK)' : ' MISMATCH')
+ );
+ beginEdit(group[Math.floor(group.length / 2)]);
+ await new Promise((r) => setTimeout(r, 40));
+ const strip2 = ov.querySelector('.edit-move[data-edge="n"]');
+ const g2 = geo(),
+ u2 = state.undo.length;
+ if (strip2) {
+ const s2 = strip2.getBoundingClientRect();
+ const x2 = s2.left + s2.width / 2,
+ y2 = s2.top + s2.height / 2;
+ fire(strip2, 'mousedown', x2, y2);
+ fire(window, 'mouseup', x2, y2);
+ msg.push(
+ 'zero-drag: steps +' +
+ (state.undo.length - u2) +
+ ', geometry ' +
+ (geo() === g2 ? 'unmoved' : 'MOVED') +
+ (state.undo.length === u2 && geo() === g2 ? ' (OK)' : ' MISMATCH')
+ );
+ const strip3 = ov.querySelector('.edit-move[data-edge="n"]');
+ if (strip3) {
+ const s3 = strip3.getBoundingClientRect();
+ fire(
+ strip3,
+ 'mousedown',
+ s3.left + s3.width / 2,
+ s3.top + s3.height / 2
+ );
+ fire(
+ window,
+ 'mousemove',
+ s3.left + s3.width / 2 + 25,
+ s3.top + s3.height / 2 + 25
+ );
+ endEdit(false);
+ fire(
+ window,
+ 'mouseup',
+ s3.left + s3.width / 2 + 25,
+ s3.top + s3.height / 2 + 25
+ );
+ drawOverlay();
+ const boxes = [...ov.querySelectorAll('.block-box')];
+ const g3 = geo();
+ msg.push(
+ 'abandoned drag: geometry ' +
+ (g3 === g2 ? 'unmoved' : 'MOVED') +
+ ', block boxes ' +
+ boxes.length +
+ (g3 === g2 ? ' (OK)' : ' MISMATCH')
+ );
+ }
+ }
+ }
+ endEdit(false);
+ return msg.join('; ');
+ };
+ const reproRotateApply = () => {
+ const p = state.paragraphs.find(
+ (q) => q.editable && !q.rotation && q.lines.length <= 2
+ );
+ if (!p) return null;
+ endEdit(false);
+ state.selection = { kind: 'para', para: p };
+ updateChrome();
+ const btnVisible = !$('rotL').hidden;
+ rotateSelection(90);
+ const rotated = state.paragraphs.some(
+ (q) => Math.abs(Math.abs(q.rotation || 0) - 90) < 3
+ );
+ return (
+ 'para rotate: btn=' +
+ btnVisible +
+ ' applied=' +
+ rotated +
+ ' (want true true)'
+ );
+ };
+ const reproLiveFidelity = async () => {
+ endEdit(false);
+ let n = 0,
+ pinned = 0,
+ worstX = 0,
+ worstXs = '',
+ spill = 0,
+ worstSpill = 0,
+ reflowed = 0,
+ worstSpillTxt = '';
+ let worstGlyph = 0,
+ worstGlyphTxt = '',
+ glyphChars = 0;
+ let multi = 0,
+ pinnedMulti = 0;
+ for (const p of state.paragraphs.filter(
+ (q) => q.editable && !q.rotation && !q.vertical
+ )) {
+ beginEdit(p);
+ await new Promise((r) => setTimeout(r, 40));
+ n++;
+ if (state.editing?.preview?.pinned) pinned++;
+ if (p.lines.length > 1) {
+ multi++;
+ if (state.editing?.preview?.pinned) pinnedMulti++;
+ }
+ const xd = lineXDrift(state.editing, state.editing.para || p);
+ if (xd && xd.worst > worstX) {
+ worstX = xd.worst;
+ worstXs = p.runs
+ .map((r) => r.text)
+ .join('')
+ .slice(0, 22);
+ }
+ const gd = glyphDrift(state.editing, p);
+ if (gd) {
+ glyphChars += gd.n;
+ if (gd.worst > worstGlyph) {
+ worstGlyph = gd.worst;
+ worstGlyphTxt =
+ p.runs
+ .map((r) => r.text)
+ .join('')
+ .slice(0, 16) +
+ ' @' +
+ gd.ch;
+ }
+ }
+ const over = editorInkOverflow(state.editing, p);
+ if (over > 0) {
+ spill++;
+ if (over > worstSpill) {
+ worstSpill = over;
+ worstSpillTxt = p.runs
+ .map((r) => r.text)
+ .join('')
+ .slice(0, 22);
+ }
+ }
+ const shown0 = editorLineCount(state.editing.editable);
+ if (shown0 !== Math.max(1, p.lines.length)) reflowed++;
+ endEdit(false);
+ }
+ renderPage();
+ window.__inkNormalized = inkOf();
+ return (
+ 'live fidelity: ' +
+ pinned +
+ '/' +
+ n +
+ " keep the page's breaks (" +
+ pinnedMulti +
+ '/' +
+ multi +
+ ' multi-line), ' +
+ reflowed +
+ ' re-wrapped, ' +
+ spill +
+ ' spill' +
+ (spill
+ ? ' (worst ' + worstSpill.toFixed(1) + 'px in "' + worstSpillTxt + '")'
+ : '') +
+ ', worst glyph drift ' +
+ worstGlyph.toFixed(2) +
+ 'pt over ' +
+ glyphChars +
+ ' glyphs' +
+ (glyphChars ? '' : ' [' + (glyphDrift.why || '?') + ']') +
+ (worstGlyphTxt ? ' ("' + worstGlyphTxt + '")' : '') +
+ ', worst line-start drift ' +
+ worstX.toFixed(2) +
+ 'px' +
+ (worstXs ? ' ("' + worstXs + '")' : '') +
+ ' (want all pinned, 0 re-wrapped, 0 spill, drift <1.5)'
+ );
+ };
+ const reproReopenAfterEdit = async () => {
+ endEdit(false);
+ const p = state.paragraphs.find(
+ (q) =>
+ q.editable &&
+ !q.rotation &&
+ q.lines.length >= 3 &&
+ q.runs.map((r) => r.text).join('').length > 60
+ );
+ if (!p) return 'reopen after edit: no multi-line paragraph (skipped)';
+ const undo0 = state.undo.length;
+ const runs = p.runs.map((r, i) => ({
+ ...r,
+ rgba: r.rgba >>> 0,
+ sourceIndex: i,
+ }));
+ runs[0] = { ...runs[0], text: 'Zz ' + runs[0].text };
+ snapshotEdit('edit text', p);
+ const u = P().commitParagraph(p.id, runs, p.format);
+ if (u) replaceParagraph(p.id, u);
+ refreshAfterMutation();
+ const after = state.paragraphs.find((q) => q.id === p.id);
+ const anchored = (after?.lines || []).filter(
+ (l) => typeof l.px === 'number'
+ ).length;
+ beginEdit(after);
+ await new Promise((r) => setTimeout(r, 80));
+ const pinned = !!state.editing?.preview?.pinned;
+ const xd = lineXDrift(state.editing, state.editing.para || after);
+ const spill = editorInkOverflow(state.editing, after);
+ const shownR = editorLineCount(state.editing.editable);
+ endEdit(false);
+ let guard = 0;
+ while (state.undo.length > undo0 && guard++ < 5)
+ restore(state.undo, state.redo);
+ refreshModel();
+ renderPage();
+ return (
+ 'reopen after edit: ' +
+ anchored +
+ '/' +
+ (after?.lines.length || 0) +
+ ' lines carry a page origin, pinned=' +
+ pinned +
+ ', editor shows ' +
+ shownR +
+ ' of ' +
+ (after?.lines.length || 0) +
+ ' lines' +
+ ', x drift ' +
+ (xd ? xd.worst.toFixed(2) : 'n/a') +
+ 'px, spill ' +
+ spill.toFixed(1) +
+ 'px (want all, true, equal, <1.5)'
+ );
+ };
+ const reproTypedUndo = async () => {
+ endEdit(false);
+ const census = () => {
+ const rows = [];
+ for (let i = 0; i < P().objectCount(); i++) {
+ const o = P().objectAt(i);
+ if (!o) continue;
+ const b = P().objectBounds(o.handle);
+ rows.push(
+ `${o.type}:${b ? b.x.toFixed(1) + ',' + b.y.toFixed(1) + ',' + b.w.toFixed(1) + 'x' + b.h.toFixed(1) : '?'}`
+ );
+ }
+ return rows;
+ };
+ const p = state.paragraphs.find(
+ (q) =>
+ q.editable &&
+ !q.rotation &&
+ q.runs.map((r) => r.text).join('').length > 24
+ );
+ if (!p) return null;
+ const before = census(),
+ inkBefore = inkOf();
+ const cv0 = $('page');
+ const snapPx = window.__undoTrace
+ ? cv0.getContext('2d').getImageData(0, 0, cv0.width, cv0.height)
+ : null;
+ beginEdit(p);
+ await new Promise((r) => setTimeout(r, 40));
+ const ed = state.editing.editable;
+ const walker = document.createTreeWalker(ed, NodeFilter.SHOW_TEXT);
+ const node = walker.nextNode();
+ if (node) node.textContent = 'Zz' + node.textContent;
+ const undoLenB = state.undo.length,
+ depthB = P().historyDepth?.() ?? '?';
+ endEdit(true);
+ const undoLenA = state.undo.length,
+ depthA = P().historyDepth?.() ?? '?';
+ const top = state.undo[state.undo.length - 1];
+ if (window.__undoTrace)
+ say(
+ ` typed-undo diag: host undo ${undoLenB}→${undoLenA} (top ${top ? (top.engine ? 'engine' : 'bytes') : '-'}), engine depth ${JSON.stringify(depthB)}→${JSON.stringify(depthA)}`
+ );
+ refreshAfterMutation();
+ const inkEdited = inkOf();
+ restore(state.undo, state.redo);
+ refreshModel();
+ renderPage();
+ const after = census(),
+ inkAfter = inkOf();
+ if (snapPx) {
+ const now = cv0
+ .getContext('2d')
+ .getImageData(0, 0, cv0.width, cv0.height);
+ let x0 = 1e9,
+ x1 = -1,
+ y0 = 1e9,
+ y1 = -1,
+ n = 0;
+ const W = Math.min(snapPx.width, now.width),
+ H = Math.min(snapPx.height, now.height);
+ for (let y = 0; y < H; y++)
+ for (let x = 0; x < W; x++) {
+ const i = (y * snapPx.width + x) * 4,
+ j2 = (y * now.width + x) * 4;
+ if (Math.abs(snapPx.data[i + 1] - now.data[j2 + 1]) > 60) {
+ n++;
+ if (x < x0) x0 = x;
+ if (x > x1) x1 = x;
+ if (y < y0) y0 = y;
+ if (y > y1) y1 = y;
+ }
+ }
+ const z = state.zoom * (devicePixelRatio || 1);
+ say(
+ ` typed-undo pixel diff: ${n}px in view box x ${(x0 / z).toFixed(0)}..${(x1 / z).toFixed(0)} y ${(y0 / z).toFixed(0)}..${(y1 / z).toFixed(0)} (page pts, y from top)`
+ );
+ say(
+ ` edited para box: x ${p.box.x.toFixed(0)}..${(p.box.x + p.box.w).toFixed(0)} yTop ${(P().pageHeight - p.box.top).toFixed(0)}..${(P().pageHeight - p.box.top + p.box.h).toFixed(0)}`
+ );
+ }
+ const missing = before.filter((r) => !after.includes(r)).length;
+ const extra = after.filter((r) => !before.includes(r)).length;
+ return (
+ 'typed undo: ' +
+ before.length +
+ ' → ' +
+ after.length +
+ ' objects, ' +
+ (missing || extra ? missing + '/' + extra + ' re-emitted, ' : '') +
+ 'ink ' +
+ inkBefore +
+ ' → edit ' +
+ inkEdited +
+ ' → undo ' +
+ inkAfter +
+ (Math.abs(inkAfter - inkBefore) <= 2
+ ? ' (restored)'
+ : Math.abs(inkAfter - (window.__inkVirgin ?? -1)) <= 2
+ ? ' (restored to virgin)'
+ : ' NOT RESTORED')
+ );
+ };
+ const reproMoveUndo = () => {
+ endEdit(false);
+ const census = () => {
+ const rows = [];
+ for (let i = 0; i < P().objectCount(); i++) {
+ const o = P().objectAt(i);
+ if (!o) continue;
+ const b = P().objectBounds(o.handle);
+ rows.push(
+ `${o.type}:${b ? b.x.toFixed(1) + ',' + b.y.toFixed(1) : '?'}`
+ );
+ }
+ return rows;
+ };
+ let obj = null;
+ for (let i = 0; i < P().objectCount(); i++) {
+ const o = P().objectAt(i);
+ if (o && o.type === OBJ.IMAGE) {
+ obj = o;
+ break;
+ }
+ }
+ if (!obj) return null;
+ const before = census();
+ snapshotEdit('move object');
+ noteMatrix(obj.handle);
+ P().translateObject(obj.handle, 40, -30);
+ refreshAfterMutation();
+ restore(state.undo, state.redo);
+ refreshModel();
+ renderPage();
+ const after = census();
+ const same =
+ before.length === after.length && before.every((r, i) => r === after[i]);
+ if (same)
+ return 'move undo: object census restored exactly (want restored)';
+ const near = (a, b) => {
+ if (a === b) return true;
+ const pa = a.match(/^(\d+):(-?[\d.]+),(-?[\d.]+)$/);
+ const pb = b.match(/^(\d+):(-?[\d.]+),(-?[\d.]+)$/);
+ if (!pa || !pb || pa[1] !== pb[1]) return false;
+ return Math.abs(pa[2] - pb[2]) <= 1.0 && Math.abs(pa[3] - pb[3]) <= 1.0;
+ };
+ if (
+ before.length === after.length &&
+ before.every((r, i) => near(r, after[i]))
+ )
+ return 'move undo: census restored within metric noise (≤1pt) (want restored)';
+ const inkNow = inkOf();
+ const virgin = Math.abs(inkNow - (window.__inkVirgin ?? -1)) <= 2;
+ const diffs = [];
+ for (
+ let i = 0;
+ i < Math.max(before.length, after.length) && diffs.length < 3;
+ i++
+ ) {
+ if (before[i] !== after[i])
+ diffs.push((before[i] ?? '-') + ' => ' + (after[i] ?? '-'));
+ }
+ return (
+ 'move undo: ' +
+ (virgin
+ ? 'restored to virgin bytes (census re-based, ink ' + inkNow + ')'
+ : 'CENSUS DIFFERS (' +
+ before.filter((r, i) => r !== after[i]).length +
+ ' objects; ' +
+ before.length +
+ '→' +
+ after.length +
+ '; e.g. ' +
+ diffs.join(' | ') +
+ ')') +
+ ' (want restored)'
+ );
+ };
+ const reproImageMove = () => {
+ const img = reproObjectMove(OBJ.IMAGE, 'image', 20, 8);
+ const path = reproObjectMove(OBJ.PATH, 'path', 40, 6, 220, 80);
+ return [img, path].filter(Boolean).join('; ') || null;
+ };
+
+ try {
+ setTool('edit');
+ say(await reproLiveFidelity());
+ {
+ const m = await reproTypedUndo();
+ if (m) say(m);
+ }
+ {
+ const m = reproMoveUndo();
+ if (m) say(m);
+ }
+ say(await reproDoubleEdit());
+ say(await reproReopenAfterEdit());
+ const target = state.paragraphs.find(
+ (p) =>
+ p.editable &&
+ p.runs
+ .map((r) => r.text)
+ .join('')
+ .includes('Revenue')
+ );
+ if (!target) {
+ say("(no 'Revenue' paragraph — skipping click-flow section)");
+ throw { skip: true };
+ }
+ const tr = paraRectView(target.box);
+ const box = [...$('overlay').querySelectorAll('.para-box')].find(
+ (d) =>
+ Math.abs(parseFloat(d.style.left) - (tr.x - BOX_PAD)) < 2 &&
+ Math.abs(parseFloat(d.style.top) - (tr.y - BOX_PAD)) < 2
+ );
+ say('para-box found: ' + !!box);
+ const c = pageToClient(target.box.x + 20, target.box.top - 10);
+ fire(box, 'mousedown', c.x, c.y);
+ fire(window, 'mouseup', c.x, c.y);
+ say('editing after click: ' + !!state.editing);
+
+ if (state.editing) {
+ const ed = state.editing.editable;
+ ed.focus();
+ say(
+ 'firstChild tag: ' +
+ ed.firstChild?.nodeName +
+ ' src=' +
+ ed.firstChild?.dataset?.src
+ );
+ ed.firstChild.textContent = ed.firstChild.textContent.replace(
+ 'Revenue',
+ 'REPRO-TYPED'
+ );
+ const parsed = parseEditor(ed, state.editing.para.runs);
+ say(
+ 'parsed text: ' +
+ parsed
+ .map((r) => r.text)
+ .join('')
+ .slice(0, 40)
+ );
+ say(
+ 'orig text: ' +
+ state.editing.para.runs
+ .map((r) => r.text)
+ .join('')
+ .slice(0, 40)
+ );
+ }
+ const away = pageToClient(300, 120);
+ fire(canvas, 'mousedown', away.x, away.y);
+ fire(window, 'mouseup', away.x, away.y);
+ say('editing after click-away: ' + !!state.editing + ' (want false)');
+ refreshModel();
+ const committed = state.paragraphs.some((p) =>
+ p.runs
+ .map((r) => r.text)
+ .join('')
+ .includes('REPRO-TYPED')
+ );
+ say('committed on click-away: ' + committed + ' (want true)');
+ say(
+ 'stale .editor count: ' +
+ document.querySelectorAll('.editor').length +
+ ' (want 0)'
+ );
+
+ const rp2 = state.paragraphs.find(
+ (p) =>
+ p.editable &&
+ p.runs
+ .map((r) => r.text)
+ .join('')
+ .includes('REPRO-TYPED')
+ );
+ if (rp2) {
+ beginEdit(rp2);
+ const w0 = state.editing.para.box.w;
+ const handle = $('overlay').querySelector(".para-handle[data-ewrap='e']");
+ say('edit-mode wrap handle present: ' + !!handle);
+ if (handle) {
+ const hr = handle.getBoundingClientRect();
+ fire(handle, 'mousedown', hr.left + 5, hr.top + 5);
+ fire(window, 'mousemove', hr.left + 5 + 80, hr.top + 5);
+ fire(window, 'mouseup', hr.left + 5 + 80, hr.top + 5);
+ const rp3 =
+ state.editing?.para ||
+ state.paragraphs.find((p) =>
+ p.runs
+ .map((r) => r.text)
+ .join('')
+ .includes('REPRO-TYPED')
+ );
+ const w1 = rp3?.box.w || 0;
+ say(
+ 'box width ' +
+ w0.toFixed(0) +
+ ' → ' +
+ w1.toFixed(0) +
+ ' (want wider), still editing: ' +
+ !!state.editing
+ );
+ }
+ if (state.editing?.para) {
+ const x0 = state.editing.para.box.x;
+ const strip = $('overlay').querySelector('.edit-move[data-edge="n"]');
+ say('edit-mode move strip present: ' + !!strip);
+ if (strip) {
+ const sr = strip.getBoundingClientRect();
+ const cx = sr.left + sr.width / 2,
+ cy = sr.top + sr.height / 2;
+ fire(strip, 'mousedown', cx, cy);
+ fire(window, 'mousemove', cx + 40, cy + 25);
+ fire(window, 'mouseup', cx + 40, cy + 25);
+ const x1 = state.editing?.para?.box.x ?? x0;
+ say(
+ 'box x ' +
+ x0.toFixed(0) +
+ ' → ' +
+ x1.toFixed(0) +
+ ' (want bigger), still editing after move: ' +
+ !!state.editing
+ );
+ }
+ }
+ if (state.editing?.para) {
+ const ed = state.editing.editable;
+ let textNode = null;
+ const tw = document.createTreeWalker(ed, NodeFilter.SHOW_TEXT);
+ for (let n = tw.nextNode(); n; n = tw.nextNode()) {
+ if (n.textContent.replace(//g, '').length >= 6) {
+ textNode = n;
+ break;
+ }
+ }
+ if (textNode && textNode.textContent.length >= 6) {
+ const range = document.createRange();
+ range.setStart(textNode, 0);
+ range.setEnd(textNode, 5);
+ const sel = window.getSelection();
+ sel.removeAllRanges();
+ sel.addRange(range);
+ const boldBtn = [...$('fToggles').children].find(
+ (b) => b.dataset.t === 'bold'
+ );
+ const br2 = boldBtn.getBoundingClientRect();
+ fire(boldBtn, 'mousedown', br2.left + 3, br2.top + 3);
+ fire(boldBtn, 'mouseup', br2.left + 3, br2.top + 3);
+ boldBtn.click();
+ const parsed = parseEditor(ed, state.editing.para.runs);
+ const boldChars = parsed
+ .filter((r) => r.bold)
+ .reduce((n, r) => n + r.text.length, 0);
+ say(
+ 'real-click bold on selection: ' +
+ boldChars +
+ ' chars bold (want 5)'
+ );
+
+ {
+ const ital = [...$('fToggles').children].find(
+ (b) => b.dataset.t === 'italic'
+ );
+ const ir = ital.getBoundingClientRect();
+ fire(ital, 'mousedown', ir.left + 3, ir.top + 3);
+ fire(ital, 'mouseup', ir.left + 3, ir.top + 3);
+ ital.click();
+ const orig = state.editing.para.runs;
+ const parsed2 = parseEditor(ed, orig);
+ const inDom = parsed2
+ .filter((r) => r.italic)
+ .reduce((n, r) => n + r.text.length, 0);
+ say(
+ 'italic reaches commit: ' +
+ inDom +
+ ' chars italic, commits=' +
+ runsDiffer(parsed2, orig) +
+ ' (want >0 and true)'
+ );
+ ital.click();
+
+ const caret = document.createRange();
+ caret.setStart(textNode, 1);
+ caret.collapse(true);
+ const s2 = window.getSelection();
+ s2.removeAllRanges();
+ s2.addRange(caret);
+ state.editing.lastSel = null;
+ fire(ital, 'mousedown', ir.left + 3, ir.top + 3);
+ fire(ital, 'mouseup', ir.left + 3, ir.top + 3);
+ ital.click();
+ const whole = parseEditor(ed, orig);
+ const wChars = whole
+ .filter((r) => r.italic)
+ .reduce((n, r) => n + r.text.length, 0);
+ say(
+ 'italic whole-box: ' +
+ wChars +
+ ' chars italic, commits=' +
+ runsDiffer(whole, orig) +
+ ' (want >0 and true)'
+ );
+ ital.click();
+ }
+ window.getSelection().removeAllRanges();
+ $('fSize').value = '21';
+ $('fSize').dispatchEvent(new Event('change'));
+ const parsed2 = parseEditor(ed, state.editing.para.runs);
+ const bigChars = parsed2
+ .filter((r) => Math.round(r.size) === 21)
+ .reduce((n, r) => n + r.text.length, 0);
+ say(
+ 'focus-stealing size change on selection: ' +
+ bigChars +
+ ' chars at 21pt (want 5)'
+ );
+ }
+ }
+ }
+ {
+ const sl = state.paragraphs.find(
+ (p) =>
+ p.editable &&
+ p.lines.length === 1 &&
+ !p.runs.some((r) => r.text.includes('\n'))
+ );
+ if (sl) {
+ beginEdit(sl);
+ const w0 = state.editing.el.offsetWidth;
+ const tn = state.editing.editable.querySelector('span')?.firstChild;
+ if (tn) {
+ tn.textContent += ' MORE WORDS APPENDED FOR WIDTH';
+ state.editing.editable.dispatchEvent(
+ new Event('input', { bubbles: true })
+ );
+ const w1 = state.editing.el.offsetWidth;
+ const ln = editorLineCount(state.editing.editable);
+ say(
+ 'single-line live widen: ' +
+ w0 +
+ ' → ' +
+ w1 +
+ 'px, lines=' +
+ ln +
+ ' (want wider, 1)'
+ );
+ }
+ endEdit(false);
+ }
+ }
+ {
+ const m = reproImageMove();
+ if (m) say(m);
+ }
+ {
+ const m = await reproSynthFont();
+ if (m) say(m);
+ }
+ {
+ const m = reproRotateChrome();
+ if (m) say(m);
+ }
+ endEdit(false);
+ let mismatches = 0,
+ checked = 0,
+ unanchored = 0,
+ pinnedN = 0,
+ spilled = 0;
+ for (const p of state.paragraphs.filter((q) => q.editable && !q.rotation)) {
+ beginEdit(p);
+ await new Promise((res) => setTimeout(res, 60));
+ const shown = editorLineCount(state.editing.editable);
+ const want = Math.max(1, p.lines.length);
+ checked++;
+ if (state.editing?.preview?.pinned) pinnedN++;
+ {
+ const over = editorInkOverflow(state.editing, p);
+ if (over > 0) {
+ spilled++;
+ say(
+ ' spill: "' +
+ p.runs
+ .map((r) => r.text)
+ .join('')
+ .slice(0, 24) +
+ '" last line sits ' +
+ over.toFixed(1) +
+ 'px below its box'
+ );
+ }
+ }
+ if (shown !== want) {
+ mismatches++;
+ say(
+ ' line mismatch: "' +
+ p.runs
+ .map((r) => r.text)
+ .join('')
+ .slice(0, 90) +
+ '" shows ' +
+ shown +
+ ' want ' +
+ want +
+ ' w=' +
+ p.box.w.toFixed(0) +
+ ' hang=' +
+ (p.format.hangIndent || 0)
+ );
+ }
+
+ if (state.editing?.locked && state.editing.preview) {
+ const pv2 = state.editing.preview;
+ const wrapR = state.editing.el.getBoundingClientRect();
+ const els = [...state.editing.editable.querySelectorAll('.eline')];
+ let worst = 0,
+ worstK = -1;
+ const pLive = state.editing.para || p;
+ const pv0 = state.editing.preview.lines[0];
+ const useExt2 =
+ pLive.lines?.length === els.length &&
+ pv0 &&
+ Math.abs(pLive.lines[0].y - pv0.baseline) < 1.6 * (pv0.size || 12);
+ els.forEach((d, k) => {
+ const L = pv2.lines[k];
+ const sp = [...d.querySelectorAll('span')].find((q) =>
+ q.textContent.trim()
+ );
+ if (!L || !sp) return;
+ const r = sp.getBoundingClientRect();
+ if (!r.height) return;
+ const cs2 = getComputedStyle(sp);
+ const F = parseFloat(cs2.fontSize);
+ const ad = fontAD(
+ cs2.fontFamily,
+ parseInt(cs2.fontWeight) >= 600,
+ cs2.fontStyle.includes('italic')
+ );
+ const got = r.top + ad.a * F;
+ const bl = useExt2 ? pLive.lines[k].y : L.baseline;
+ const wantY = wrapR.top + EDITOR_PAD + (pv2.top - bl) * state.zoom;
+ const dvv = Math.abs(got - wantY);
+ if (dvv > worst) {
+ worst = dvv;
+ worstK = k;
+ }
+ });
+ const sizeMax = Math.max(...p.runs.map((r) => r.size || 12));
+ if (worst > Math.max(2, 0.16 * sizeMax * state.zoom)) {
+ mismatches++;
+ say(
+ ' baseline drift: "' +
+ p.runs
+ .map((r) => r.text)
+ .join('')
+ .slice(0, 24) +
+ '" line ' +
+ worstK +
+ ' off by ' +
+ worst.toFixed(1) +
+ 'px (tol ' +
+ Math.max(2, 0.16 * sizeMax * state.zoom).toFixed(1) +
+ ')'
+ );
+ }
+ const xd = lineXDrift(state.editing, pLive);
+ if (xd && xd.worst > 1.5) {
+ mismatches++;
+ say(
+ ' x drift: "' +
+ p.runs
+ .map((r) => r.text)
+ .join('')
+ .slice(0, 24) +
+ '" line ' +
+ xd.k +
+ ' off by ' +
+ xd.worst.toFixed(1) +
+ 'px'
+ );
+ }
+ if (xd && xd.loose) unanchored += xd.loose;
+ }
+ endEdit(false);
+ }
+ say(
+ 'editor line-count sweep: ' +
+ (checked - mismatches) +
+ '/' +
+ checked +
+ ' exact (want all)' +
+ ', ' +
+ pinnedN +
+ '/' +
+ checked +
+ " keep the page's own breaks" +
+ (spilled ? ', ' + spilled + ' SPILL past their box' : ', 0 spill') +
+ (unanchored ? ' [' + unanchored + ' lines not page-anchored]' : '')
+ );
+
+ {
+ let sanOk = 0,
+ sanN = 0;
+ window.__sanctityProbe = true;
+ const sanPick = (q) =>
+ JSON.stringify({
+ b: [q.box.x, q.box.top, q.box.w, q.box.h].map((v) => +v.toFixed(2)),
+ r: q.runs.map((r) => [
+ r.text,
+ r.family,
+ Math.round(r.size),
+ r.rgba >>> 0,
+ !!r.bold,
+ !!r.italic,
+ !!r.underline,
+ !!r.strike,
+ r.script | 0,
+ r.renderMode | 0,
+ ]),
+ });
+ for (const p of state.paragraphs
+ .filter((q) => q.editable && !q.rotation)
+ .slice(0, 8)) {
+ const undo0 = state.undo.length;
+ const before = sanPick(p);
+ beginEdit(p);
+ await new Promise((res) => setTimeout(res, 30));
+ endEdit(true);
+ const now = state.paragraphs.find((q) => q.id === p.id);
+ sanN++;
+ if (state.undo.length === undo0 && now && sanPick(now) === before)
+ sanOk++;
+ else {
+ for (const w of window.__sanctityWhy || []) say(' differ: ' + w);
+ window.__sanctityWhy = [];
+ say(
+ ' sanctity broken: "' +
+ p.runs
+ .map((r) => r.text)
+ .join('')
+ .slice(0, 26) +
+ '"' +
+ (state.undo.length !== undo0 ? ' (committed)' : ' (mutated)')
+ );
+ }
+ }
+ say('click sanctity: ' + sanOk + '/' + sanN + ' unchanged (want all)');
+ }
+ {
+ const m = reproArabicCommit();
+ if (m) say(m);
+ }
+ {
+ const m = reproRtlBackspace();
+ if (m) say(m);
+ }
+ {
+ const m = reproRtlArrows();
+ if (m) say(m);
+ }
+ {
+ const m = reproFindReplace();
+ if (m) say(m);
+ }
+ {
+ const m = reproBlockSelect();
+ if (m) say(m);
+ }
+ {
+ const m = await reproBlockEdit();
+ if (m) say(m);
+ }
+ {
+ const m = reproRotateApply();
+ if (m) say(m);
+ }
+ {
+ const m = reproTextSelection();
+ if (m) say(m);
+ }
+ {
+ const m = await reproSpelling();
+ if (m) say(m);
+ }
+ say(reproUndoParity());
+ } catch (e) {
+ if (e?.stopNow) {
+ try {
+ say(reproUndoParity());
+ } catch {}
+ } else if (e?.skip) {
+ try {
+ {
+ const m = reproImageMove();
+ if (m) say(m);
+ }
+ {
+ const m = await reproSynthFont();
+ if (m) say(m);
+ }
+ {
+ const m = reproRotateChrome();
+ if (m) say(m);
+ }
+ endEdit(false);
+ let mismatches = 0,
+ checked = 0,
+ unanchored = 0;
+ for (const p of state.paragraphs.filter(
+ (q) => q.editable && !q.rotation
+ )) {
+ beginEdit(p);
+ await new Promise((res) => setTimeout(res, 60));
+ const shown = editorLineCount(state.editing.editable);
+ const want = Math.max(1, p.lines.length);
+ checked++;
+ if (shown !== want) {
+ mismatches++;
+ say(
+ ' line mismatch: "' +
+ p.runs
+ .map((r) => r.text)
+ .join('')
+ .slice(0, 90) +
+ '" shows ' +
+ shown +
+ ' want ' +
+ want +
+ ' w=' +
+ p.box.w.toFixed(0) +
+ ' hang=' +
+ (p.format.hangIndent || 0)
+ );
+ }
+ const xd = lineXDrift(state.editing, state.editing?.para || p);
+ if (xd && xd.worst > 1.5) {
+ mismatches++;
+ say(
+ ' x drift: "' +
+ p.runs
+ .map((r) => r.text)
+ .join('')
+ .slice(0, 24) +
+ '" line ' +
+ xd.k +
+ ' off by ' +
+ xd.worst.toFixed(1) +
+ 'px'
+ );
+ }
+ if (xd && xd.loose) unanchored += xd.loose;
+ endEdit(false);
+ }
+ say(
+ 'editor line-count sweep: ' +
+ (checked - mismatches) +
+ '/' +
+ checked +
+ ' exact (want all)' +
+ (unanchored ? ' [' + unanchored + ' lines not page-anchored]' : '')
+ );
+
+ {
+ let sanOk = 0,
+ sanN = 0;
+ window.__sanctityProbe = true;
+ const sanPick = (q) =>
+ JSON.stringify({
+ b: [q.box.x, q.box.top, q.box.w, q.box.h].map(
+ (v) => +v.toFixed(2)
+ ),
+ r: q.runs.map((r) => [
+ r.text,
+ r.family,
+ Math.round(r.size),
+ r.rgba >>> 0,
+ !!r.bold,
+ !!r.italic,
+ !!r.underline,
+ !!r.strike,
+ r.script | 0,
+ r.renderMode | 0,
+ ]),
+ });
+ for (const p of state.paragraphs
+ .filter((q) => q.editable && !q.rotation)
+ .slice(0, 8)) {
+ const undo0 = state.undo.length;
+ const before = sanPick(p);
+ beginEdit(p);
+ await new Promise((res) => setTimeout(res, 30));
+ endEdit(true);
+ const now = state.paragraphs.find((q) => q.id === p.id);
+ sanN++;
+ if (state.undo.length === undo0 && now && sanPick(now) === before)
+ sanOk++;
+ else {
+ for (const w of window.__sanctityWhy || []) say(' differ: ' + w);
+ window.__sanctityWhy = [];
+ say(
+ ' sanctity broken: "' +
+ p.runs
+ .map((r) => r.text)
+ .join('')
+ .slice(0, 26) +
+ '"' +
+ (state.undo.length !== undo0 ? ' (committed)' : ' (mutated)')
+ );
+ }
+ }
+ say(
+ 'click sanctity: ' + sanOk + '/' + sanN + ' unchanged (want all)'
+ );
+ say(reproUndoParity());
+ }
+ {
+ const m = reproArabicCommit();
+ if (m) say(m);
+ }
+ {
+ const m = reproRtlBackspace();
+ if (m) say(m);
+ }
+ {
+ const m = reproRtlArrows();
+ if (m) say(m);
+ }
+ {
+ const m = reproFindReplace();
+ if (m) say(m);
+ }
+ {
+ const m = reproBlockSelect();
+ if (m) say(m);
+ }
+ {
+ const m = await reproBlockEdit();
+ if (m) say(m);
+ }
+ {
+ const m = reproRotateApply();
+ if (m) say(m);
+ }
+ {
+ const m = reproTextSelection();
+ if (m) say(m);
+ }
+ {
+ const m = await reproSpelling();
+ if (m) say(m);
+ }
+ } catch (e2) {
+ say('EXCEPTION: ' + (e2.stack || e2));
+ }
+ } else say('EXCEPTION: ' + (e.stack || e));
+ }
+
+ const pre = document.createElement('pre');
+ pre.id = 'reproout';
+ pre.textContent = log.join('\n');
+ pre.style.cssText =
+ 'position:fixed;left:0;bottom:0;z-index:99;color:#0ff;background:#000;font:12px monospace;padding:6px';
+ document.body.appendChild(pre);
+ document.title = log.join(' | ');
+}
+
+async function runE2E() {
+ const results = [];
+ const check = (c, l) => results.push((c ? 'PASS ' : 'FAIL ') + l);
+ try {
+ setTool('edit');
+ const target = state.paragraphs.find(
+ (p) =>
+ p.editable &&
+ p.runs
+ .map((r) => r.text)
+ .join('')
+ .includes('Revenue')
+ );
+ check(!!target, 'found paragraph to edit');
+ beginEdit(target);
+ check(!!state.editing, 'edit overlay opened');
+ const ed = state.editing.editable;
+ ed.firstChild.textContent = ed.firstChild.textContent.replace(
+ 'Revenue',
+ 'BROWSER-EDIT'
+ );
+ endEdit(true);
+ check(!state.editing, 'overlay committed/closed');
+ refreshModel();
+ const edited = state.paragraphs.find((p) =>
+ p.runs
+ .map((r) => r.text)
+ .join('')
+ .includes('BROWSER-EDIT')
+ );
+ check(!!edited, 'typed edit reflowed into the paragraph');
+
+ const t2 = state.paragraphs.find(
+ (p) =>
+ p.editable &&
+ p.runs
+ .map((r) => r.text)
+ .join('')
+ .includes('operating')
+ );
+ if (t2) {
+ beginEdit(t2);
+ state.editing.editable.firstChild.textContent += ' CLICKAWAY';
+ $('stage').dispatchEvent(new MouseEvent('mousedown', { bubbles: true }));
+ check(!state.editing, 'click outside closed the overlay');
+ refreshModel();
+ check(
+ state.paragraphs.some((p) =>
+ p.runs
+ .map((r) => r.text)
+ .join('')
+ .includes('CLICKAWAY')
+ ),
+ 'click outside committed the edit'
+ );
+ }
+
+ {
+ const ed = document.createElement('div');
+ const mk = (t) => {
+ const s = document.createElement('span');
+ s.dataset.src = 0;
+ s.dataset.family = 'Times';
+ s.dataset.size = '35';
+ s.dataset.rgba = '255';
+ s.dataset.bold = '0';
+ s.dataset.italic = '0';
+ s.dataset.underline = '0';
+ s.dataset.strike = '0';
+ s.dataset.script = '0';
+ s.textContent = t;
+ return s;
+ };
+ ed.appendChild(mk('1997'));
+ const div = document.createElement('div');
+ div.appendChild(mk('ANNUAL REPORT'));
+ ed.appendChild(div);
+ const parsedTxt = parseEditor(ed, [])
+ .map((r) => r.text)
+ .join('');
+ check(
+ parsedTxt === '1997\nANNUAL REPORT',
+ 'block boundary → hard break preserved (' +
+ JSON.stringify(parsedTxt) +
+ ')'
+ );
+ const ed2 = document.createElement('div');
+ const s2 = mk('1997');
+ s2.appendChild(document.createElement('br'));
+ s2.appendChild(document.createTextNode('ANNUAL'));
+ ed2.appendChild(s2);
+ check(
+ parseEditor(ed2, [])
+ .map((r) => r.text)
+ .join('') === '1997\nANNUAL',
+ '
→ hard break preserved'
+ );
+ }
+
+ setTool('edit');
+ const rp = state.paragraphs.find(
+ (p) =>
+ p.editable &&
+ p.runs
+ .map((r) => r.text)
+ .join('')
+ .includes('Styled fragments')
+ );
+ if (rp) {
+ beginEdit(rp);
+ const ed = state.editing.editable;
+ const walkE = document.createTreeWalker(ed, NodeFilter.SHOW_TEXT);
+ let firstText = walkE.nextNode();
+ while (
+ firstText &&
+ firstText.textContent.replace(/[\u200B\s]/g, '').length < 6
+ )
+ firstText = walkE.nextNode();
+ const range = document.createRange();
+ range.setStart(firstText, 0);
+ range.setEnd(firstText, Math.min(6, firstText.textContent.length));
+ const s = window.getSelection();
+ s.removeAllRanges();
+ s.addRange(range);
+ toggleStyle('bold');
+ const parsed = parseEditor(ed, rp.runs);
+ const boldRuns = parsed.filter((r) => r.bold);
+ const plainRuns = parsed.filter((r) => !r.bold);
+ check(
+ boldRuns.length >= 1 &&
+ plainRuns.length >= 1 &&
+ boldRuns[0].text.length <= 8,
+ 'range styling bolded only the selection (' +
+ boldRuns.map((r) => JSON.stringify(r.text.slice(0, 8))).join(',') +
+ ')'
+ );
+ endEdit(true);
+ refreshModel();
+ }
+
+ setTool('edit');
+ const sp = state.paragraphs.find(
+ (p) =>
+ p.editable &&
+ p.runs
+ .map((r) => r.text)
+ .join('')
+ .includes('back to plain')
+ );
+ if (sp) {
+ beginEdit(sp);
+ window.getSelection().selectAllChildren(state.editing.editable);
+ toggleStyle('underline');
+ endEdit(true);
+ refreshModel();
+ const after = state.paragraphs.find((p) =>
+ p.runs
+ .map((r) => r.text)
+ .join('')
+ .includes('back to plain')
+ );
+ check(
+ after && after.runs.some((r) => r.underline),
+ 'style-only edit (underline) committed without text change'
+ );
+ }
+
+ setTool('edit');
+ drawOverlay();
+ const a =
+ state.paragraphs.find(
+ (p) =>
+ p.editable &&
+ p.runs
+ .map((r) => r.text)
+ .join('')
+ .includes('Section Overview')
+ ) || state.paragraphs.find((p) => p.editable);
+ if (a) {
+ beginEdit(a);
+ state.editing.editable.querySelector('span').textContent += ' SWITCH1';
+ const b = state.paragraphs.find((p) => p.editable && p.id !== a.id);
+ if (b) {
+ beginEdit(b);
+ check(
+ state.editing?.para?.id === b.id,
+ 'switched editing to another paragraph'
+ );
+ endEdit(true);
+ refreshModel();
+ check(
+ state.paragraphs.some((p) =>
+ p.runs
+ .map((r) => r.text)
+ .join('')
+ .includes('SWITCH1')
+ ),
+ 'first paragraph auto-committed when switching'
+ );
+ }
+ }
+
+ const edited2 = state.paragraphs.find((p) =>
+ p.runs
+ .map((r) => r.text)
+ .join('')
+ .includes('BROWSER-EDIT')
+ );
+ state.selection = { kind: 'para', para: edited2 || edited };
+ toggleStyle('bold');
+ const after = state.paragraphs.find((p) =>
+ p.runs
+ .map((r) => r.text)
+ .join('')
+ .includes('BROWSER-EDIT')
+ );
+ check(after && after.runs.some((r) => r.bold), 'inspector bold applied');
+
+ const n = replaceAll('percent', 'PCT', false);
+ check(n >= 1, 'replace all worked (' + n + ')');
+
+ const before = state.paragraphs.length;
+ snapshotEdit('add text');
+ const created = P().addParagraph(
+ 72,
+ 250,
+ 200,
+ [
+ {
+ text: 'E2E added box',
+ family: 'Helvetica',
+ size: 12,
+ rgba: 0x000000ff >>> 0,
+ bold: false,
+ italic: false,
+ underline: false,
+ strike: false,
+ script: 0,
+ renderMode: 0,
+ strokeRgba: 0,
+ strokeWidth: 1,
+ hScale: 1,
+ rise: 0,
+ sourceIndex: -1,
+ },
+ ],
+ { align: 0, lineSpacing: 1.2, charSpacing: 0, paraSpacing: 0 }
+ );
+ if (created) state.paragraphs.push(created);
+ check(created && state.paragraphs.length === before + 1, 'add text box');
+
+ P().loadPage(0);
+ refreshModel();
+ let imgHandle = 0;
+ for (let i = 0; i < P().objectCount(); i++) {
+ const o = P().objectAt(i);
+ if (o && o.type === OBJ.IMAGE) {
+ imgHandle = o.handle;
+ break;
+ }
+ }
+ check(!!imgHandle, 'found image object');
+ if (imgHandle) {
+ const b0 = P().objectBounds(imgHandle);
+ P().scaleObject(imgHandle, 1.5, 1.5, b0.x, b0.y);
+ const b1 = P().objectBounds(imgHandle);
+ check(Math.abs(b1.w - b0.w * 1.5) < 2, 'object resize (scale) applied');
+ P().rotateObject(imgHandle, 90);
+ check(true, 'object rotate applied');
+ const objN = P().objectCount();
+ const clone = P().duplicateImage(imgHandle, 15, -15);
+ check(
+ clone && P().objectCount() === objN + 1,
+ 'image duplicate created new object'
+ );
+ check(P().arrangeObject(clone, 'back'), 'arrange z-order applied');
+ }
+
+ const pxW = 30,
+ pxH = 30,
+ px = new Uint8Array(pxW * pxH * 4);
+ for (let i = 0; i < px.length; i += 4) {
+ px[i] = 200;
+ px[i + 1] = 60;
+ px[i + 2] = 60;
+ px[i + 3] = 255;
+ }
+ const nBefore = P().objectCount();
+ const imgObj = P().insertImage(px, pxW, pxH, 100, 100, 60, 60);
+ check(
+ imgObj && P().objectCount() === nBefore + 1,
+ 'add image inserted object'
+ );
+
+ setTool('addText');
+ const pCount = state.paragraphs.length;
+ beginNewTextBox(120, 400, 14);
+ check(
+ !!state.editing && !!state.editing.newGeom,
+ 'add-text opened inline empty box'
+ );
+ state.editing.editable.firstChild.textContent =
+ 'Typed a brand new text box';
+ endEdit(true);
+ refreshModel();
+ check(
+ state.paragraphs.some((p) =>
+ p.runs
+ .map((r) => r.text)
+ .join('')
+ .includes('brand new text box')
+ ),
+ 'inline add-text created a paragraph'
+ );
+
+ setTool('edit');
+ const mp = state.paragraphs.find(
+ (p) =>
+ p.editable &&
+ p.runs
+ .map((r) => r.text)
+ .join('')
+ .includes('BROWSER-EDIT')
+ );
+ if (mp) {
+ const x0 = mp.box.x;
+ P().moveParagraph(mp.id, 20, 0);
+ refreshModel();
+ const mp2 = state.paragraphs.find((p) =>
+ p.runs
+ .map((r) => r.text)
+ .join('')
+ .includes('BROWSER-EDIT')
+ );
+ check(mp2 && Math.abs(mp2.box.x - x0 - 20) < 1.5, 'paragraph moved 20pt');
+ const lc0 = mp2.lines.length;
+ const rz = P().resizeParagraph(mp2.id, mp2.box.w * 0.5);
+ check(
+ rz && rz.lines.length >= lc0,
+ 'paragraph wrap-width resize reflowed'
+ );
+ }
+
+ refreshModel();
+ const dsrc = state.paragraphs.find(
+ (p) => p.editable && !p.rotation && p.runs.length >= 1
+ );
+ if (dsrc) {
+ const dup = P().duplicateParagraph(dsrc.id, 12, -12);
+ check(!!dup, 'paragraph duplicate created');
+ if (dup) {
+ check(
+ dup.runs[0].family === dsrc.runs[0].family &&
+ Math.abs(dup.box.w - dsrc.box.w) < 1.0,
+ 'paragraph duplicate keeps font + width (' +
+ dup.runs[0].family +
+ ', ' +
+ dup.box.w.toFixed(1) +
+ ' vs ' +
+ dsrc.box.w.toFixed(1) +
+ ')'
+ );
+ }
+ }
+
+ {
+ const buf = await (await fetch('sample.pdf')).arrayBuffer();
+ const dt = new DataTransfer();
+ dt.items.add(new File([buf], 'dropped.pdf', { type: 'application/pdf' }));
+ const ev = new Event('drop', { bubbles: true, cancelable: true });
+ ev.dataTransfer = dt;
+ const nameBefore = state.fileName;
+ window.dispatchEvent(
+ Object.defineProperty(ev, 'dataTransfer', { value: dt })
+ );
+ for (let w = 0; w < 40 && !state.fileName.includes('dropped'); w++)
+ await new Promise((r) => setTimeout(r, 150));
+ check(
+ state.fileName.includes('dropped') && state.paragraphs.length > 0,
+ 'drag-drop opens the PDF (' +
+ state.fileName +
+ ', ' +
+ state.paragraphs.length +
+ ' paras)'
+ );
+ }
+
+ {
+ const scopeSel = $('editScope');
+ const anyPara = state.paragraphs.find((q) => q.editable);
+ const pc =
+ anyPara && textToPage(anyPara, anyPara.box.x + 4, anyPara.box.top - 4);
+ scopeSel.value = 'image';
+ scopeSel.dispatchEvent(new Event('change'));
+ const paraHitInImageScope = pc ? hitTestParagraph(pc.x, pc.y) : null;
+ scopeSel.value = 'text';
+ scopeSel.dispatchEvent(new Event('change'));
+ let objHitInTextScope = null;
+ for (let i = 0; i < P().objectCount() && !objHitInTextScope; i++) {
+ const o = P().objectAt(i);
+ if (o && (o.type === OBJ.IMAGE || o.type === OBJ.PATH)) {
+ const b = P().objectBounds(o.handle);
+ if (b)
+ objHitInTextScope = hitTestObject(b.x + b.w / 2, b.y + b.h / 2);
+ }
+ }
+ const paraHitInTextScope = pc ? hitTestParagraph(pc.x, pc.y) : null;
+ scopeSel.value = 'all';
+ scopeSel.dispatchEvent(new Event('change'));
+ check(
+ !paraHitInImageScope && !objHitInTextScope && !!paraHitInTextScope,
+ 'edit scope gates hit-testing (image blocks text; text blocks objects)'
+ );
+ }
+ } catch (e) {
+ results.push('EXCEPTION: ' + ((e && e.stack) || e));
+ }
+ const ok = results.every((r) => r.startsWith('PASS'));
+ const out = document.createElement('pre');
+ out.id = 'e2eout';
+ out.textContent = results.join('\n');
+ out.style.cssText =
+ 'position:fixed;left:0;bottom:0;z-index:99;color:#0f0;background:#000;font:11px monospace;padding:4px';
+ document.body.appendChild(out);
+ document.title = ok ? 'E2E-GREEN' : 'E2E-FAIL';
+}
+
+function renderPage() {
+ const eng = P();
+ let hide =
+ state.editing?.para && !state.editing.pristine
+ ? eng.paragraphObjects(state.editing.para.id)
+ : null;
+ if (hide && state.editing.para.runs) {
+ for (const r of state.editing.para.runs)
+ if (r.atomic && r.obj) hide.push(r.obj);
+ }
+ const img = eng.renderPage(state.zoom * devicePixelRatio, hide);
+ const canvas = $('page');
+ if (canvas.width !== img.width || canvas.height !== img.height) {
+ canvas.width = img.width;
+ canvas.height = img.height;
+ }
+ const cssW = eng.pageWidth * state.zoom + 'px';
+ const cssH = eng.pageHeight * state.zoom + 'px';
+ if (canvas.style.width !== cssW) canvas.style.width = cssW;
+ if (canvas.style.height !== cssH) canvas.style.height = cssH;
+ const ctx = canvas.getContext('2d');
+ ctx.putImageData(new ImageData(img.data, img.width, img.height), 0, 0);
+ const overlay = $('overlay');
+ if (overlay.style.width !== cssW) overlay.style.width = cssW;
+ if (overlay.style.height !== cssH) overlay.style.height = cssH;
+ drawOverlay();
+ drawRulers();
+}
+
+let scanningPages = false;
+
+function refreshModel() {
+ engineStepEnd();
+ state.paragraphs = P().buildModel();
+ if (scanningPages) return;
+ refreshPageExtras();
+}
+
+function refreshPageExtras() {
+ prewarmDocFonts();
+ const invisibles = state.paragraphs.filter((p) => p.invisible).length;
+ if (
+ invisibles &&
+ !state.paragraphs.some((p) => p.editable) &&
+ state.ocrNoticeFor !== P().pageIndex
+ ) {
+ state.ocrNoticeFor = P().pageIndex;
+ toast(
+ 'Scanned page: its ' +
+ invisibles +
+ ' text blocks are an invisible ' +
+ 'OCR layer over the picture — searchable and selectable, but there ' +
+ 'are no glyphs to edit.'
+ );
+ }
+ if (state.patternPageFor !== P().pageIndex) {
+ state.patternPageFor = P().pageIndex;
+ state.patternPage = false;
+ const want = P().pageIndex,
+ bytes = P()._originalBytes;
+ if (bytes) {
+ pageHasPatternFill(bytes, want)
+ .then((v) => {
+ if (state.patternPageFor === want) state.patternPage = v;
+ })
+ .catch(() => {});
+ }
+ }
+}
+
+let liveBlockMove = null;
+const liveShift = (q) => {
+ if (!liveBlockMove || !q.blockId || q.blockId !== liveBlockMove.blockId)
+ return q;
+ const dT = pageToText(q, liveBlockMove.dx, liveBlockMove.dy);
+ return { ...q, box: { ...q.box, x: q.box.x + dT.x, top: q.box.top + dT.y } };
+};
+
+function blockPlacement(para) {
+ const pl = paraPlacement(liveShift(para));
+ if (!para.blockId) return pl;
+ let x = pl.x,
+ y = pl.y,
+ r = pl.x + pl.w,
+ b = pl.y + pl.h;
+ for (const q of state.paragraphs) {
+ if (q.blockId !== para.blockId || q.id === para.id) continue;
+ const o = paraPlacement(liveShift(q));
+ if (Math.abs((o.rot || 0) - (pl.rot || 0)) > 0.01) continue;
+ x = Math.min(x, o.x);
+ y = Math.min(y, o.y);
+ r = Math.max(r, o.x + o.w);
+ b = Math.max(b, o.y + o.h);
+ }
+ return { ...pl, x, y, w: r - x, h: b - y };
+}
+
+function drawOverlay() {
+ const ov = $('overlay');
+ const keepParaLayer = inPreviewPass && !!ov.querySelector('.para-layer');
+ [...ov.children].forEach((c) => {
+ if (c === state.editing?.el) return;
+ if (keepParaLayer && c.classList.contains('para-layer')) return;
+ c.remove();
+ });
+
+ const selPara =
+ state.selection?.kind === 'para' ? state.selection.para : null;
+ const editingId = state.editing?.para?.id;
+
+ if (state.tool === 'edit' && scopeAllowsPara() && !keepParaLayer) {
+ const layer = document.createElement('div');
+ layer.className = 'para-layer';
+ ov.appendChild(layer);
+ const blocks = new Map();
+ for (const para of state.paragraphs) {
+ if (!para.blockId || para.invisible || !para.editable) continue;
+ const pl = paraPlacement(liveShift(para));
+ const b = blocks.get(para.blockId);
+ if (!b)
+ blocks.set(para.blockId, {
+ x: pl.x,
+ y: pl.y,
+ r: pl.x + pl.w,
+ b: pl.y + pl.h,
+ });
+ else {
+ b.x = Math.min(b.x, pl.x);
+ b.y = Math.min(b.y, pl.y);
+ b.r = Math.max(b.r, pl.x + pl.w);
+ b.b = Math.max(b.b, pl.y + pl.h);
+ }
+ }
+ const openBlock = state.editing?.para?.blockId || 0;
+ for (const [id, g] of blocks) {
+ const div = document.createElement('div');
+ div.className =
+ 'para-box block-box' + (id === openBlock ? ' active' : '');
+ applyPlacement(
+ div,
+ { x: g.x, y: g.y, w: g.r - g.x, h: g.b - g.y },
+ BOX_PAD
+ );
+ div.style.pointerEvents = 'none';
+ layer.appendChild(div);
+ }
+ for (const para of state.paragraphs) {
+ if (para.id === editingId) continue;
+ const inBlock = !!para.blockId && para.editable && !para.invisible;
+ if (para.invisible) continue;
+ if (!para.editable) continue;
+ const div = document.createElement('div');
+ div.className = 'para-box' + (inBlock ? ' in-block' : '');
+ applyPlacement(div, paraPlacement(liveShift(para)), BOX_PAD);
+ if (para.editable) {
+ div.addEventListener('mousedown', (e) => {
+ e.stopPropagation();
+ if (e.shiftKey) {
+ toggleMultiSelect({ t: 'para', para });
+ return;
+ }
+ beginEdit(para, { x: e.clientX, y: e.clientY });
+ });
+ attachParaLongPress(div, para);
+ }
+ layer.appendChild(div);
+ }
+ }
+
+ if (state.editing?.para || state.editing?.newGeom) {
+ const wrap = ov.querySelector('.editor');
+ if (wrap) {
+ const blk = state.editing.para?.blockId
+ ? blockPlacement(state.editing.para)
+ : null;
+ const w = blk ? blk.w : wrap.offsetWidth;
+ const h = blk ? blk.h : wrap.offsetHeight;
+ const chrome = document.createElement('div');
+ chrome.className = 'edit-chrome';
+ chrome.style.left = blk ? blk.x + 'px' : wrap.style.left;
+ chrome.style.top = blk ? blk.y + 'px' : wrap.style.top;
+ chrome.style.width = w + 'px';
+ chrome.style.height = h + 'px';
+ chrome.style.transform = blk
+ ? blk.deg
+ ? `rotate(${blk.deg}deg)`
+ : ''
+ : wrap.style.transform;
+ chrome.style.transformOrigin = '0 0';
+ const S = 7;
+ for (const [edge, sx, sy, sw, sh] of [
+ ['n', -S, -S, w + 2 * S, S],
+ ['s', -S, h, w + 2 * S, S],
+ ['w', -S, 0, S, h],
+ ['e', w, 0, S, h],
+ ]) {
+ const st = document.createElement('div');
+ st.className = 'edit-move';
+ st.dataset.edge = edge;
+ Object.assign(st.style, {
+ left: sx + 'px',
+ top: sy + 'px',
+ width: sw + 'px',
+ height: sh + 'px',
+ });
+ chrome.appendChild(st);
+ }
+ if (blk) {
+ ov.appendChild(chrome);
+ return;
+ }
+ for (const [name, fx] of [
+ ['w', 0],
+ ['e', 1],
+ ]) {
+ const hd = document.createElement('div');
+ hd.className = 'obj-handle para-handle';
+ hd.dataset.ewrap = name;
+ hd.style.left = w * fx - 5 + 'px';
+ hd.style.top = h / 2 - 5 + 'px';
+ hd.style.cursor = 'ew-resize';
+ chrome.appendChild(hd);
+ }
+ const rh = document.createElement('div');
+ rh.className = 'obj-handle rot-handle';
+ rh.title = 'Drag to rotate';
+ rh.style.left = w / 2 - 5 + 'px';
+ rh.style.top = '-24px';
+ chrome.appendChild(rh);
+ const stem = document.createElement('div');
+ stem.className = 'rot-stem';
+ stem.style.left = w / 2 + 'px';
+ chrome.appendChild(stem);
+ ov.appendChild(chrome);
+ }
+ return;
+ }
+
+ if (state.selection?.kind === 'multi') {
+ for (const it of state.selection.items) {
+ const box = document.createElement('div');
+ box.className = 'multi-box';
+ if (it.t === 'para') applyPlacement(box, paraPlacement(it.para));
+ else {
+ const r = objRectView(it.bounds);
+ Object.assign(box.style, {
+ left: r.x + 'px',
+ top: r.y + 'px',
+ width: r.w + 'px',
+ height: r.h + 'px',
+ });
+ }
+ box.dataset.idx = state.selection.items.indexOf(it);
+ ov.appendChild(box);
+ }
+ return;
+ }
+
+ if (state.selection?.kind === 'object') {
+ const r0 = objRectView(state.selection.bounds);
+ const r = {
+ x: r0.x - BOX_PAD,
+ y: r0.y - BOX_PAD,
+ w: r0.w + 2 * BOX_PAD,
+ h: r0.h + 2 * BOX_PAD,
+ };
+ const box = document.createElement('div');
+ box.className = 'obj-box';
+ Object.assign(box.style, {
+ left: r.x + 'px',
+ top: r.y + 'px',
+ width: r.w + 'px',
+ height: r.h + 'px',
+ });
+ box.addEventListener('mousemove', (e) => {
+ const pt = toPage(e);
+ box.style.cursor = hitTestParagraphStrong(pt.px, pt.py) ? 'text' : 'move';
+ });
+ ov.appendChild(box);
+ const HANDLES = [
+ ['nw', 0, 0],
+ ['n', 0.5, 0],
+ ['ne', 1, 0],
+ ['e', 1, 0.5],
+ ['se', 1, 1],
+ ['s', 0.5, 1],
+ ['sw', 0, 1],
+ ['w', 0, 0.5],
+ ];
+ for (const [name, fx, fy] of HANDLES) {
+ const h = document.createElement('div');
+ h.className = 'obj-handle';
+ h.dataset.handle = name;
+ h.style.left = r.x + r.w * fx - 5 + 'px';
+ h.style.top = r.y + r.h * fy - 5 + 'px';
+ h.style.cursor =
+ name === 'n' || name === 's'
+ ? 'ns-resize'
+ : name === 'e' || name === 'w'
+ ? 'ew-resize'
+ : name === 'nw' || name === 'se'
+ ? 'nwse-resize'
+ : 'nesw-resize';
+ ov.appendChild(h);
+ }
+ const rh = document.createElement('div');
+ rh.className = 'obj-handle rot-handle';
+ rh.title = 'Drag to rotate';
+ rh.style.left = r.x + r.w / 2 - 5 + 'px';
+ rh.style.top = r.y - 24 + 'px';
+ ov.appendChild(rh);
+ const stem = document.createElement('div');
+ stem.className = 'rot-stem';
+ stem.style.left = r.x + r.w / 2 + 'px';
+ stem.style.top = r.y - 14 + 'px';
+ stem.style.height = '14px';
+ ov.appendChild(stem);
+ } else if (selPara) {
+ const pl = blockPlacement(selPara);
+ const chrome = document.createElement('div');
+ chrome.className = 'sel-chrome';
+ applyPlacement(chrome, pl);
+ const box = document.createElement('div');
+ box.className = 'sel-para';
+ Object.assign(box.style, {
+ left: -BOX_PAD + 'px',
+ top: -BOX_PAD + 'px',
+ width: pl.w + 2 * BOX_PAD + 'px',
+ height: pl.h + 2 * BOX_PAD + 'px',
+ });
+ chrome.appendChild(box);
+ for (const [name, fx] of [
+ ['w', 0],
+ ['e', 1],
+ ]) {
+ const h = document.createElement('div');
+ h.className = 'obj-handle para-handle';
+ h.dataset.phandle = name;
+ h.style.left = pl.w * fx - 5 + 'px';
+ h.style.top = pl.h / 2 - 5 + 'px';
+ h.style.cursor = 'ew-resize';
+ chrome.appendChild(h);
+ }
+ const rh = document.createElement('div');
+ rh.className = 'obj-handle rot-handle';
+ rh.title = 'Drag to rotate';
+ rh.style.left = pl.w / 2 - 5 + 'px';
+ rh.style.top = '-24px';
+ chrome.appendChild(rh);
+ const stem = document.createElement('div');
+ stem.className = 'rot-stem';
+ stem.style.left = pl.w / 2 + 'px';
+ chrome.appendChild(stem);
+ ov.appendChild(chrome);
+ }
+}
+
+let engineStepOpen = false;
+function engineStepBegin(label) {
+ if (engineStepOpen) engineStepEnd();
+ P().historyBeginStep(label);
+ engineStepOpen = true;
+ queueMicrotask(engineStepEnd);
+}
+function engineStepEnd() {
+ if (!engineStepOpen) return;
+ engineStepOpen = false;
+ P().historyEndStep();
+}
+function noteMatrix(handle) {
+ P().historyNoteMatrix(handle);
+}
+function noteZOrder(handle) {
+ P().historyNoteZOrder(handle);
+}
+function noteInsert(handle) {
+ P().historyNoteInsert(handle);
+}
+
+function syncHistoryPins() {
+ const eng = P();
+ if (!eng) return;
+ const pins = new Set();
+ for (const s of state.undo) if (Number.isInteger(s.page)) pins.add(s.page);
+ for (const s of state.redo) if (Number.isInteger(s.page)) pins.add(s.page);
+ eng.pinnedPages = pins;
+ eng.onPageEvicted = (idx) => {
+ state.undo = state.undo.filter((s) => s.page !== idx);
+ state.redo = state.redo.filter((s) => s.page !== idx);
+ };
+}
+
+function snapshotEdit(label, para) {
+ if (window.__undoTrace)
+ (window.__ledger ||= []).push(
+ `push:${label}${para && (para.unwrapsForms || para.sharesObjects) ? '(bytes:para)' : ''}`
+ );
+ if (para && (para.unwrapsForms || para.sharesObjects)) {
+ snapshot();
+ return;
+ }
+ if (P().pageWasNormalized()) {
+ snapshot();
+ return;
+ }
+ if (P().normalizeFontsForEdit() > 0) {
+ snapshot();
+ return;
+ }
+ engineStepBegin(label || 'edit');
+ pushUndo({
+ engine: true,
+ epoch: state.docEpoch,
+ page: P().pageIndex,
+ label,
+ what: para
+ ? (para.runs || [])
+ .map((r) => r.text)
+ .join('')
+ .slice(0, 18)
+ : '',
+ });
+ syncHistoryPins();
+}
+
+function snapshot() {
+ if (window.__undoTrace) (window.__ledger ||= []).push('push:BYTES');
+ engineStepEnd();
+ if (!state.dirty && P()._originalBytes) {
+ pushUndo({ bytes: P()._originalBytes, page: P().pageIndex });
+ return;
+ }
+ P().normalizeFontsForEdit();
+ if (
+ !state.patternPage &&
+ !P().pageRegenHostile() &&
+ !(P()._spliceOk !== false && P()._splicePlans?.length)
+ )
+ P().generateContent();
+ const bytes = P().save({ incremental: snapshotPrefersIncremental() });
+ if (bytes) pushUndo({ bytes, page: P().pageIndex });
+}
+
+function snapshotPrefersIncremental() {
+ const size = P()._originalBytes?.length || 0;
+ return size > 0 && size <= INCREMENTAL_SNAPSHOT_LIMIT;
+}
+function restore(from, to) {
+ if (window.__undoTrace)
+ (window.__ledger ||= []).push(
+ `restore:${from === state.undo ? 'undo' : 'redo'}@${from.length}${from.length ? (from[from.length - 1].engine ? ':eng:' + (from[from.length - 1].label || '') : ':bytes') : ''}`
+ );
+ if (!from.length) return;
+ engineStepEnd();
+ const undoing = from === state.undo;
+ while (
+ from.length &&
+ from[from.length - 1].engine &&
+ from[from.length - 1].epoch !== state.docEpoch
+ ) {
+ from.pop();
+ }
+ if (!from.length) return;
+ if (from[from.length - 1].engine) {
+ const next = from[from.length - 1];
+ if (Number.isInteger(next.page) && next.page !== P().pageIndex) {
+ endEdit(false);
+ goToPage(next.page);
+ refreshModel();
+ }
+ const snap = from.pop();
+ endEdit(false);
+ const ok = undoing ? P().historyUndo() : P().historyRedo();
+ if (!ok) {
+ const depth = P().historyDepth();
+ if ((undoing ? depth.undo : depth.redo) > 0) from.push(snap);
+ toast('Nothing to ' + (undoing ? 'undo' : 'redo'));
+ updateChrome();
+ return;
+ }
+ to.push(snap);
+ syncHistoryPins();
+ refreshModel();
+ state.selection = null;
+ state.dirty = true;
+ renderPage();
+ updateChrome();
+ return;
+ }
+ const cur = P().save({ incremental: true });
+ const curPage = P().pageIndex;
+ const snap = from.pop();
+ endEdit(false);
+ try {
+ P().reopen(snap.bytes, snap.page);
+ state.docEpoch++;
+ } catch (err) {
+ from.push(snap);
+ toast('Undo failed: ' + err.message);
+ return;
+ }
+ if (cur) to.push({ bytes: cur, page: curPage, epoch: state.docEpoch });
+ if (snap.bytes === P()._originalBytes) state.dirty = false;
+ refreshModel();
+ state.selection = null;
+ state.dirty = true;
+ renderPage();
+ updateChrome();
+}
+
+const docFonts = new Map();
+const docFontsReady = new Map();
+const docFontsNull = new Set();
+let docFontSeq = 0;
+let inPreviewPass = false;
+
+if (typeof document !== 'undefined' && document.fonts?.addEventListener) {
+ document.fonts.addEventListener('loadingdone', () => {
+ lockedEpoch++;
+ });
+}
+
+function isLoadableSfnt(bytes) {
+ if (bytes.length < 4) return false;
+ const b = bytes;
+ const tag = ((b[0] << 24) | (b[1] << 16) | (b[2] << 8) | b[3]) >>> 0;
+ return (
+ tag === 0x00010000 ||
+ tag === 0x4f54544f ||
+ tag === 0x74727565 ||
+ tag === 0x74746366 ||
+ tag === 0x774f4646 ||
+ tag === 0x774f4632
+ );
+}
+
+async function ensureRunFont(para, runIndex, run) {
+ const key = docFontKey(run);
+ const hit = docFonts.get(key);
+ if (hit) return hit.promise;
+ const promise = (async () => {
+ const load = async (bytes) => {
+ const cssFamily = 'ecdoc' + docFontSeq++;
+ const face = new FontFace(cssFamily, bytes.buffer);
+ await face.load();
+ document.fonts.add(face);
+ return cssFamily;
+ };
+ try {
+ const synth = P().synthRunFont(para.id, runIndex);
+ if (synth === 'dishonest') return null;
+ if (synth) return await load(synth);
+ } catch {}
+ const bytes = P().runFontData(para.id, runIndex);
+ try {
+ if (bytes && isLoadableSfnt(bytes)) return await load(bytes);
+ } catch {}
+ return null;
+ })();
+ docFonts.set(key, { promise });
+ promise.then((fam) => {
+ if (fam) {
+ docFontsReady.set(key, fam);
+ lockedEpoch++;
+ } else {
+ docFontsNull.add(key);
+ }
+ });
+ return promise;
+}
+
+async function applyDocFonts(para, ed) {
+ const spans = [...ed.querySelectorAll('span[data-src]')];
+ const pending = [];
+ for (const s of spans) {
+ if (s.dataset.atomic === '1') continue;
+ const idx = parseInt(s.dataset.src);
+ if (!(idx >= 0) || idx >= para.runs.length) continue;
+ const run = para.runs[idx];
+ const key = docFontKey(run);
+ const fam = docFontsReady.get(key);
+ if (fam) {
+ if (s.dataset.docfont !== fam) {
+ s.dataset.docfont = fam;
+ s.dataset.docfontFor = s.dataset.family;
+ s.dataset.docfontB = run.bold === 2 ? '2' : run.bold ? '1' : '0';
+ s.dataset.docfontI = run.italic === 2 ? '2' : run.italic ? '1' : '0';
+ applySpanStyle(s);
+ }
+ continue;
+ }
+ if (docFontsNull.has(key)) continue;
+ pending.push([s, idx, run]);
+ }
+ if (pending.length) {
+ await Promise.all(
+ pending.map(async ([s, idx, run]) => {
+ const fam = await ensureRunFont(para, idx, run);
+ if (fam && state.editing?.editable === ed) {
+ s.dataset.docfont = fam;
+ s.dataset.docfontFor = s.dataset.family;
+ s.dataset.docfontB = run.bold === 2 ? '2' : run.bold ? '1' : '0';
+ s.dataset.docfontI = run.italic === 2 ? '2' : run.italic ? '1' : '0';
+ applySpanStyle(s);
+ }
+ })
+ );
+ }
+ if (state.editing?.editable === ed) fitEditorWidth();
+}
+
+function editorLineCount(ed) {
+ if (ed.dataset?.locked === '1') return ed.querySelectorAll('.eline').length;
+ const range = document.createRange();
+ range.selectNodeContents(ed);
+ const rects = [...range.getClientRects()]
+ .filter((r) => r.height > 0 && r.width > 0)
+ .sort((a, b) => a.top - b.top);
+ let lines = 0,
+ lastBottom = -1e9;
+ for (const r of rects) {
+ if (r.top >= lastBottom - r.height * 0.4) {
+ lines++;
+ lastBottom = r.bottom;
+ } else lastBottom = Math.max(lastBottom, r.bottom);
+ }
+ return lines;
+}
+
+function fitEditorWidth() {
+ const e = state.editing;
+ if (!e?.para || !e.el?.isConnected) return;
+ if (e.locked) {
+ if (!inPreviewPass) drawOverlay();
+ return;
+ }
+ if (e.para.rotation) {
+ drawOverlay();
+ return;
+ }
+ if (e.singleLine) {
+ drawOverlay();
+ return;
+ }
+ const target = Math.max(1, e.para.lines.length);
+ const baseW = e.baseW ?? parseFloat(e.el.style.width);
+ const step = Math.max(2, baseW * 0.01);
+ const minW = baseW * 0.88,
+ maxW = baseW * 1.25;
+ const setW = (w) => {
+ e.el.style.width = w + 'px';
+ return editorLineCount(e.editable);
+ };
+ let w = baseW,
+ n = setW(w),
+ guard = 0;
+ if (n > target) {
+ while (n > target && w < maxW && guard++ < 60) n = setW((w += step));
+ } else if (n < target) {
+ while (n < target && w > minW && guard++ < 60) n = setW((w -= step));
+ if (n > target) setW((w += step));
+ }
+ drawOverlay();
+}
+
+function placeCaret(ed, clientX, clientY) {
+ const sel = window.getSelection();
+ let range = null;
+ if (clientX != null) {
+ if (document.caretRangeFromPoint)
+ range = document.caretRangeFromPoint(clientX, clientY);
+ else if (document.caretPositionFromPoint) {
+ const p = document.caretPositionFromPoint(clientX, clientY);
+ if (p) {
+ range = document.createRange();
+ range.setStart(p.offsetNode, p.offset);
+ range.collapse(true);
+ }
+ }
+ }
+ if (range && ed.contains(range.startContainer)) {
+ if (ed.dataset.locked === '1') {
+ const off = lockedPointOffset(
+ ed,
+ range.startContainer,
+ range.startOffset
+ );
+ if (off >= 0) {
+ lockedCaretSet(ed, off);
+ return;
+ }
+ }
+ sel.removeAllRanges();
+ sel.addRange(range);
+ } else {
+ sel.selectAllChildren(ed);
+ sel.collapseToEnd();
+ }
+}
+
+let fontPrewarmTimer = 0;
+function prewarmDocFonts() {
+ clearTimeout(fontPrewarmTimer);
+ const queue = [];
+ const seen = new Set();
+ for (const para of state.paragraphs) {
+ if (!para.editable || !para.runs) continue;
+ para.runs.forEach((run, idx) => {
+ const key = docFontKey(run);
+ if (seen.has(key) || docFonts.has(key)) return;
+ seen.add(key);
+ queue.push([para, idx, run]);
+ });
+ }
+ const step = () => {
+ const item = queue.shift();
+ if (!item) return;
+ if (P()?.doc) ensureRunFont(item[0], item[1], item[2]).catch(() => {});
+ fontPrewarmTimer = setTimeout(step, 40);
+ };
+ fontPrewarmTimer = setTimeout(step, 250);
+}
+
+function unpristine() {
+ const es = state.editing;
+ if (!es?.pristine) return;
+ es.pristine = false;
+ es.el.classList.remove('pristine');
+ renderPage();
+}
+
+function openEditor(spec, caret) {
+ endEdit(true);
+ let openPv = null;
+ const ov = $('overlay');
+ const wrap = document.createElement('div');
+ wrap.className = 'editor' + (spec.para?.blockId ? ' in-block' : '');
+ const pad = EDITOR_PAD;
+ const ed = document.createElement('div');
+ ed.contentEditable = 'true';
+ ed.spellcheck = $('spellchk')?.checked || false;
+ ed.dir = 'auto';
+
+ let singleLine = false;
+ if (spec.para) {
+ const para = spec.para;
+ state.selection = { kind: 'para', para };
+ const pl = paraPlacement(para);
+ singleLine =
+ para.lines.length === 1 && !para.runs.some((r) => r.text.includes('\n'));
+ Object.assign(wrap.style, {
+ width: pl.w + pad * 2 + 'px',
+ minHeight: pl.h + pad * 2 + 'px',
+ padding: pad + 'px',
+ });
+ if (singleLine) {
+ wrap.style.width = 'auto';
+ wrap.style.minWidth = pl.w + pad * 2 + 'px';
+ }
+ positionWrap(wrap, para);
+ ed.style.lineHeight = para.format.lineSpacing || 1.2;
+ ed.style.textAlign =
+ ['left', 'center', 'right', 'justify'][para.format.align] || 'left';
+ ed.style.letterSpacing =
+ para.format.charSpacing || 0
+ ? para.format.charSpacing * state.zoom + 'px'
+ : '';
+ if (para.vertical) {
+ ed.style.writingMode = 'vertical-rl';
+ wrap.style.height = pl.h + pad * 2 + 'px';
+ wrap.style.width = 'auto';
+ wrap.style.minWidth = pl.w + pad * 2 + 'px';
+ singleLine = false;
+ }
+ let lockedPv = null;
+ if (!para.vertical) {
+ try {
+ lockedPv = P().previewParagraph(
+ para.id,
+ runsToInput(para.runs),
+ para.format
+ );
+ } catch {
+ lockedPv = null;
+ }
+ if (lockedPv && lockedPv.lines.length) {
+ openPv = lockedPv;
+ renderLockedLines(ed, para.runs, lockedPv, para, para.box.x);
+ singleLine = false;
+ const countable = (s) => s.replace(/[\u200B\u2060\n]/g, '').length;
+ const wantChars = para.runs.reduce(
+ (n, r) => n + (r.text ? countable(r.text) : 0),
+ 0
+ );
+ const gotChars = countable(ed.textContent);
+ if (wantChars > 0 && gotChars < wantChars * 0.98) {
+ ed.textContent = '';
+ delete ed.dataset.locked;
+ ed.style.textAlign = '';
+ openPv = null;
+ lockedPv = null;
+ }
+ } else {
+ lockedPv = null;
+ }
+ }
+ if (!lockedPv) {
+ for (let i = 0; i < para.runs.length; i++)
+ ed.appendChild(runSpan(para.runs[i], i));
+ }
+ } else {
+ const g = spec.newGeom;
+ const vx = g.x * state.zoom,
+ vy = (P().pageHeight - g.yTop) * state.zoom;
+ Object.assign(wrap.style, {
+ left: vx - pad + 'px',
+ top: vy - pad + 'px',
+ width: g.width * state.zoom + 'px',
+ minHeight: g.size * 1.4 * state.zoom + 'px',
+ padding: pad + 'px',
+ });
+ ed.style.lineHeight = 1.25;
+ ed.appendChild(
+ runSpan(
+ {
+ text: '',
+ family: g.seedFamily || 'Helvetica',
+ size: g.size,
+ rgba: cssHexToRgba($('fColor').value || '#000000'),
+ bold: !!g.seedBold,
+ italic: false,
+ underline: false,
+ strike: false,
+ script: 0,
+ },
+ -1
+ )
+ );
+ }
+ ed.style.whiteSpace =
+ singleLine || ed.dataset.locked === '1' ? 'pre' : 'pre-wrap';
+ ed.style.overflowWrap = 'break-word';
+ ed.style.width = '100%';
+ wrap.appendChild(ed);
+ ov.appendChild(wrap);
+ setTimeout(() => {
+ try {
+ P().normalizeFontsForEdit();
+ } catch {}
+ }, 0);
+ state.editing = {
+ para: spec.para || null,
+ el: wrap,
+ editable: ed,
+ newGeom: spec.newGeom || null,
+ baseW:
+ parseFloat(wrap.style.width) ||
+ (spec.para ? paraPlacement(spec.para).w : 0),
+ lastSel: null,
+ singleLine,
+ locked: ed.dataset.locked === '1',
+ preview: openPv,
+ pristine: !!spec.para && ed.dataset.locked === '1',
+ };
+ if (state.editing.pristine) wrap.classList.add('pristine');
+ else if (spec.para) renderPage();
+ ed.focus();
+ placeCaret(ed, caret?.x, caret?.y);
+ const refocus = () => {
+ if (state.editing?.editable === ed && document.activeElement !== ed)
+ ed.focus({ preventScroll: true });
+ };
+ setTimeout(refocus, 0);
+ wrap.addEventListener('mouseup', () => setTimeout(refocus, 0));
+ if ($('spellLang').value) ed.lang = $('spellLang').value;
+ ed.addEventListener('keydown', (e) => {
+ if (
+ (e.key === 'Backspace' || e.key === 'Delete') &&
+ state.editing?.locked &&
+ !e.metaKey &&
+ !e.ctrlKey &&
+ !e.altKey
+ ) {
+ const dir = e.key === 'Backspace' ? -1 : 1;
+ const INV = /^[\u200B\u2060]*$/;
+ const target = (() => {
+ const sel = window.getSelection();
+ if (!sel.rangeCount || !sel.isCollapsed) return null;
+ const r = sel.getRangeAt(0);
+ let n = r.startContainer;
+ const o = r.startOffset;
+ if (n.nodeType === Node.TEXT_NODE) {
+ const t = n.textContent;
+ if (dir < 0 && !INV.test(t.slice(0, o))) return null;
+ if (dir > 0 && !INV.test(t.slice(o))) return null;
+ if (n.parentElement?.dataset?.atomic === '1') return n.parentElement;
+ } else if (n.childNodes.length) {
+ const kid = dir < 0 ? n.childNodes[o - 1] : n.childNodes[o];
+ if (kid?.dataset?.atomic === '1') return kid;
+ if (kid) n = kid;
+ }
+ let cur = n;
+ while (
+ cur.parentNode &&
+ cur.parentNode !== ed &&
+ !cur.parentNode.classList?.contains('eline')
+ )
+ cur = cur.parentNode;
+ let sib = dir < 0 ? cur.previousSibling : cur.nextSibling;
+ while (
+ sib &&
+ ((sib.nodeType === Node.TEXT_NODE && INV.test(sib.textContent)) ||
+ (sib.nodeType === 1 &&
+ sib.dataset?.atomic !== '1' &&
+ INV.test(sib.textContent)))
+ )
+ sib = dir < 0 ? sib.previousSibling : sib.nextSibling;
+ return sib?.dataset?.atomic === '1' ? sib : null;
+ })();
+ if (target) {
+ e.preventDefault();
+ unpristine();
+ const v = lockedCaretGet(state.editing.editable);
+ target.remove();
+ lockedCaretSet(
+ state.editing.editable,
+ dir < 0 ? Math.max(0, v - 1) : v
+ );
+ schedulePreview();
+ return;
+ }
+ }
+ if (
+ (e.key === 'ArrowLeft' || e.key === 'ArrowRight') &&
+ state.editing?.locked &&
+ !e.metaKey &&
+ !e.ctrlKey &&
+ !e.altKey &&
+ paraIsRtl(state.editing.para)
+ ) {
+ const from = lockedCaretGet(ed);
+ if (from >= 0) {
+ const step = e.key === 'ArrowRight' ? 1 : -1;
+ const to = Math.max(0, from + step);
+ if (e.shiftKey) {
+ const cur = lockedSelRange(ed);
+ const anchor = cur
+ ? cur.start === from
+ ? cur.end
+ : cur.start
+ : from;
+ lockedSelSet(ed, Math.min(anchor, to), Math.max(anchor, to));
+ } else {
+ lockedCaretSet(ed, to);
+ }
+ e.preventDefault();
+ return;
+ }
+ }
+ if (e.key === 'Escape') {
+ clearTextSelection();
+ e.preventDefault();
+ endEdit(true);
+ } else if (
+ e.key === 'Enter' &&
+ !e.shiftKey &&
+ state.editing?.para &&
+ listPrefixOf(state.editing.para, state.editing.editable)
+ ) {
+ e.preventDefault();
+ continueListItem(
+ listPrefixOf(state.editing.para, state.editing.editable)
+ );
+ } else if (
+ e.key === 'Enter' &&
+ state.editing?.locked &&
+ state.editing.para
+ ) {
+ e.preventDefault();
+ lockedInsertBreak(state.editing);
+ } else if (
+ e.key === 'Tab' &&
+ state.editing?.para &&
+ listPrefixOf(state.editing.para, state.editing.editable)
+ ) {
+ e.preventDefault();
+ adjustListLevel(e.shiftKey ? -1 : 1);
+ }
+ });
+ ed.addEventListener('beforeinput', (e) => {
+ if (e.inputType !== 'historyUndo' && e.inputType !== 'historyRedo')
+ unpristine();
+ if (e.inputType === 'historyUndo' || e.inputType === 'historyRedo') {
+ e.preventDefault();
+ restore(
+ e.inputType === 'historyUndo' ? state.undo : state.redo,
+ e.inputType === 'historyUndo' ? state.redo : state.undo
+ );
+ }
+ });
+ ed.addEventListener('compositionstart', () => {
+ if (state.editing) state.editing.composing = true;
+ });
+ ed.addEventListener('compositionend', () => {
+ if (!state.editing) return;
+ state.editing.composing = false;
+ if (state.editing.locked) schedulePreview();
+ });
+ ed.addEventListener('dblclick', (e) => {
+ const sel = window.getSelection();
+ if (!sel || !sel.anchorNode || !ed.contains(sel.anchorNode)) return;
+ const map = [];
+ const walk = (n) => {
+ for (const c of n.childNodes) {
+ if (c.nodeType === Node.TEXT_NODE) {
+ for (let k = 0; k < c.textContent.length; k++)
+ map.push({ node: c, offset: k, ch: c.textContent[k] });
+ } else if (c.nodeName !== 'BR') {
+ walk(c);
+ }
+ }
+ };
+ walk(ed);
+ if (!map.length) return;
+ let idx = map.findIndex(
+ (m) => m.node === sel.anchorNode && m.offset === sel.anchorOffset
+ );
+ if (idx < 0) idx = map.findIndex((m) => m.node === sel.anchorNode);
+ if (idx < 0) return;
+ if (!isWordChar(map[idx].ch) && idx > 0 && isWordChar(map[idx - 1].ch))
+ idx--;
+ if (!isWordChar(map[idx].ch)) return;
+ let from = idx;
+ let to = idx;
+ while (from > 0 && isWordChar(map[from - 1].ch)) from--;
+ while (to + 1 < map.length && isWordChar(map[to + 1].ch)) to++;
+ const range = document.createRange();
+ range.setStart(map[from].node, map[from].offset);
+ range.setEnd(map[to].node, map[to].offset + 1);
+ sel.removeAllRanges();
+ sel.addRange(range);
+ e.preventDefault();
+ });
+
+ ed.addEventListener('input', () => {
+ if (!state.editing) return;
+ unpristine();
+ const es = state.editing;
+ if (es.locked) {
+ schedulePreview();
+ return;
+ }
+ if (es.singleLine && es.para) {
+ if (ed.querySelector('div,br') || ed.textContent.includes('\n')) {
+ es.singleLine = false;
+ const pl2 = paraPlacement(es.para);
+ es.el.style.width = pl2.w + EDITOR_PAD * 2 + 'px';
+ ed.style.whiteSpace = 'pre-wrap';
+ } else if (!es.para.rotation) {
+ const align = es.para.format.align;
+ if (align === 1 || align === 2) {
+ const pl2 = paraPlacement(es.para);
+ const grow = es.el.offsetWidth - (pl2.w + EDITOR_PAD * 2);
+ if (grow > 0) {
+ es.el.style.left = pl2.x - (align === 1 ? grow / 2 : grow) + 'px';
+ }
+ }
+ }
+ }
+ drawOverlay();
+ });
+ if (spec.para) {
+ buildAtomicSnips(state.editing, spec.para);
+ if (state.editing.snips?.size) {
+ ed.querySelectorAll('[data-atomic="1"]').forEach(applyAtomicSpanStyle);
+ }
+ if (ed.dataset.locked !== '1') fitEditorWidth();
+ applyDocFonts(spec.para, ed);
+ if (ed.dataset.locked === '1' && !state.editing.preview) {
+ state.editing.preview = P().previewParagraph(
+ spec.para.id,
+ runsToInput(spec.para.runs),
+ spec.para.format
+ );
+ }
+ } else {
+ drawOverlay();
+ }
+ updateChrome();
+ setTimeout(() => {
+ const es = state.editing;
+ if (!es || es.el !== wrap || !es.locked || !es.para || !es.pristine) return;
+ try {
+ const eng = P();
+ const hide = eng.paragraphObjects(es.para.id) || [];
+ if (es.para.runs) {
+ for (const r of es.para.runs) if (r.atomic && r.obj) hide.push(r.obj);
+ }
+ eng.renderPage(state.zoom * devicePixelRatio, hide);
+ const pv = es.preview;
+ if (
+ pv &&
+ !es.para.rotation &&
+ !es.para.vertical &&
+ !es.para.sharesObjects &&
+ !es.para.unwrapsForms
+ ) {
+ const runs = parseEditor(es.editable, es.para.runs);
+ if (runs.length) {
+ const padM = EDITOR_PAD / state.zoom;
+ const topDown = eng.pageHeight - pv.top;
+ eng.renderParagraphLive(
+ es.para.id,
+ runsToInput(runs),
+ es.para.format,
+ state.zoom * devicePixelRatio,
+ pv.x - padM,
+ topDown - padM,
+ pv.width + 2 * padM,
+ pv.height + 2 * padM
+ );
+ }
+ }
+ } catch {}
+ }, 30);
+}
+
+function runsToInput(runs) {
+ return runs.map((r, i) => ({
+ ...r,
+ rgba: r.rgba >>> 0,
+ sourceIndex: r.sourceIndex ?? i,
+ }));
+}
+
+function lockedSlices(pv, runs) {
+ const out = [];
+ const total = runs.reduce((a, r) => a + r.text.length, 0);
+ const flats = pv.lines.map((L) => L.flat);
+ for (let k = 0; k < flats.length; k++) {
+ if (flats[k] >= 0) continue;
+ if (k === 0) {
+ flats[k] = 0;
+ continue;
+ }
+ const prev = pv.lines[k - 1];
+ const core = prev.cx && prev.cx.length ? prev.cx.length - 1 : 0;
+ flats[k] = Math.min(total, flats[k - 1] + core + (prev.hard ? 1 : 0));
+ }
+ for (let k = 0; k < pv.lines.length; k++) {
+ const L = { ...pv.lines[k], flat: flats[k] };
+ const N = k + 1 < pv.lines.length ? { flat: flats[k + 1] } : null;
+ const segs = [];
+ if (L.flat >= 0) {
+ let at = L.flat;
+ const end = N && N.flat >= 0 ? N.flat : total;
+ let base = 0;
+ for (let r = 0; r < runs.length && at < end; r++) {
+ const len = runs[r].text.length;
+ if (base + len > at) {
+ const txt = runs[r].text.slice(at - base, Math.min(len, end - base));
+ if (txt) segs.push({ src: r, text: txt });
+ at = base + Math.min(len, end - base);
+ }
+ base += len;
+ }
+ }
+ let nl = false;
+ if (segs.length) {
+ const lastSeg = segs[segs.length - 1];
+ if (lastSeg.text.endsWith('\n')) {
+ lastSeg.text = lastSeg.text.slice(0, -1);
+ nl = true;
+ if (!lastSeg.text) segs.pop();
+ }
+ } else {
+ nl = true;
+ }
+ out.push({ segs, nl });
+ }
+ return out;
+}
+
+const _fontADCache = new Map();
+const docFontKey = (r) =>
+ `${r.family}|${r.bold === 1 || r.bold === true ? 1 : 0}${r.italic === 1 || r.italic === true ? 1 : 0}`;
+function fontAD(family, bold, italic) {
+ const key = family + '|' + (bold ? 1 : 0) + (italic ? 1 : 0);
+ let m = _fontADCache.get(key);
+ if (m) return m;
+ const c = (fontAD._cv ||= document.createElement('canvas')).getContext('2d');
+ c.font = `${italic ? 'italic ' : ''}${bold ? '700 ' : ''}100px ${family}, sans-serif`;
+ const t = c.measureText('Hg');
+ const a = (t.fontBoundingBoxAscent ?? 78) / 100;
+ const d = (t.fontBoundingBoxDescent ?? 22) / 100;
+ m = { a, d };
+ _fontADCache.set(key, m);
+ return m;
+}
+
+function exactMeasureW(text, run, z) {
+ const docFam = docFontsReady.get(docFontKey(run));
+ const stack =
+ (docFam ? `"${docFam}", ` : '') + WEB_FALLBACK(run.family, run.fallback);
+ const eb = docFam
+ ? run.bold === 2
+ : run.bold === 1 || run.bold === true || run.bold === 2;
+ const ei = docFam
+ ? run.italic === 2
+ : run.italic === 1 || run.italic === true || run.italic === 2;
+ const cx = (exactMeasureW._cv ||=
+ document.createElement('canvas')).getContext('2d');
+ cx.font = `${ei ? 'italic ' : ''}${eb ? '700 ' : ''}${(run.size || 12) * z}px ${stack}`;
+ const ls = (state.editing?.para?.format?.charSpacing || 0) * z;
+ return cx.measureText(text).width * (run.hScale || 1) + ls * text.length;
+}
+
+function buildLockedLineExact(div, segs, cx, runs, z) {
+ const no = (why) => {
+ div.dataset.exact = '0:' + why;
+ return false;
+ };
+ if (!Array.isArray(cx) || cx.length < 2) return no('no cx');
+ let lineText = '';
+ const charRun = [];
+ for (const sg of segs) {
+ const r = runs[sg.src];
+ for (const ch of sg.text) {
+ lineText += ch;
+ charRun.push(sg.src);
+ }
+ }
+ const rawCore = lineText.replace(/[ \t]+$/, '');
+ const slotOf = [];
+ const charOf = [];
+ for (let q = 0; q < rawCore.length; q++) {
+ if (rawCore[q] === '\u00AD') {
+ slotOf.push(charOf.length);
+ continue;
+ }
+ slotOf.push(charOf.length);
+ charOf.push(q);
+ }
+ const core = charOf.map((q) => rawCore[q]).join('');
+ if (cx.length !== core.length + 1)
+ return no('cx ' + cx.length + ' vs core ' + (core.length + 1));
+ if (/[--ࣿיִ-ﻼ]/.test(core)) return no('rtl');
+ const isWS = (ch) => ch === ' ' || ch === '\t';
+ const emit = (a, b, host) => {
+ let s = a;
+ while (s < b) {
+ const src = charRun[s];
+ let e = s;
+ while (e < b && charRun[e] === src) e++;
+ host.appendChild(
+ runSpan({ ...runs[src], text: lineText.slice(s, e) }, src)
+ );
+ s = e;
+ }
+ };
+ const measured = (a, b) => {
+ let w = 0,
+ s = a;
+ while (s < b) {
+ const src = charRun[s];
+ let e = s;
+ while (e < b && charRun[e] === src) e++;
+ w += exactMeasureW(lineText.slice(s, e), runs[src], z);
+ s = e;
+ }
+ return w;
+ };
+ const dpr = window.devicePixelRatio || 1;
+ const snap = (v) => Math.round(v * z * dpr) / dpr;
+ const isAt = (q) => !!runs[charRun[q]]?.atomic;
+ const groups = [];
+ {
+ let a = 0;
+ while (a < rawCore.length) {
+ const ws = isWS(rawCore[a]);
+ const at = isAt(a);
+ let b = a;
+ if (at) b = a + 1;
+ else
+ while (b < rawCore.length && isWS(rawCore[b]) === ws && !isAt(b)) b++;
+ groups.push({ a, b, ws, at });
+ a = b;
+ }
+ }
+ const lead = new Array(groups.length).fill(0);
+ for (let g = 0; g + 1 < groups.length; g++) {
+ const cur = groups[g],
+ next = groups[g + 1];
+ if (cur.ws || cur.at || !next.ws || next.at) continue;
+ const boxW =
+ snap(cx[slotOf[cur.b] ?? cx.length - 1]) - snap(cx[slotOf[cur.a]]);
+ const mW = measured(cur.a, cur.b);
+ const spare = boxW - mW;
+ if (spare > 0.5 && spare < boxW * 0.5) lead[g] = spare;
+ }
+ let i = 0;
+ let gi = -1;
+ while (i < rawCore.length) {
+ gi++;
+ const ws = isWS(rawCore[i]);
+ const at0 = isAt(i);
+ let j = i;
+ if (at0) j = i + 1;
+ else while (j < rawCore.length && isWS(rawCore[j]) === ws && !isAt(j)) j++;
+ let boxW = snap(cx[slotOf[j] ?? cx.length - 1]) - snap(cx[slotOf[i]]);
+ if (lead[gi] > 0) boxW -= lead[gi];
+ else if (gi > 0 && lead[gi - 1] > 0) boxW += lead[gi - 1];
+ const box = document.createElement('span');
+ box.dataset.fitw = '1';
+ box.style.display = 'inline-block';
+ box.style.whiteSpace = 'pre';
+ box.style.verticalAlign = 'baseline';
+ box.style.width = boxW + 'px';
+ let grpF = 0;
+ for (let q = i; q < j; q++)
+ grpF = Math.max(grpF, runs[charRun[q]]?.size || 0);
+ if (grpF > 0) box.style.fontSize = grpF * z + 'px';
+ const mW = isWS(rawCore[i]) || at0 ? boxW : measured(i, j);
+ const k = boxW > 0 && mW > 0.5 ? boxW / mW : 1;
+ const overflow = mW - boxW;
+ const squeeze = boxW > 0 && overflow > Math.max(1.5, 0.045 * boxW);
+ if (at0) {
+ const anchor = () => {
+ const a = document.createElement('span');
+ a.style.fontSize = (runs[charRun[i]]?.size || 12) * z + 'px';
+ a.appendChild(document.createTextNode('\u200B'));
+ return a;
+ };
+ box.appendChild(anchor());
+ box.appendChild(
+ runSpan({ ...runs[charRun[i]], text: rawCore[i] }, charRun[i])
+ );
+ box.appendChild(anchor());
+ } else if (squeeze) {
+ const chars = Math.max(1, j - i);
+ const fontPx = (runs[charRun[i]]?.size || 12) * z;
+ const perGap = overflow / chars;
+ const inner = document.createElement('span');
+ inner.style.display = 'inline-block';
+ inner.style.whiteSpace = 'pre';
+ if (chars > 1 && perGap <= 0.12 * fontPx) {
+ inner.style.letterSpacing = `${-perGap}px`;
+ } else {
+ inner.style.transform = `scaleX(${k})`;
+ inner.style.transformOrigin = '0 50%';
+ }
+ emit(i, j, inner);
+ box.appendChild(inner);
+ } else {
+ emit(i, j, box);
+ }
+ div.appendChild(box);
+ i = j;
+ }
+ if (lineText.length > rawCore.length)
+ emit(rawCore.length, lineText.length, div);
+ div.dataset.exact = '1';
+ return true;
+}
+
+function editorInkOverflow(es, para) {
+ if (!es?.editable || !para?.lines?.length) return 0;
+ const lines = [...es.editable.querySelectorAll('.eline')];
+ let sp = null,
+ li = -1;
+ for (let i = lines.length - 1; i >= 0 && !sp; i--) {
+ sp =
+ [...lines[i].querySelectorAll('span')].find((q) =>
+ q.textContent.trim()
+ ) || null;
+ if (sp) li = i;
+ }
+ if (!sp) return 0;
+ const r = sp.getBoundingClientRect();
+ if (!r.height) return 0;
+ const cs = getComputedStyle(sp);
+ const F = parseFloat(cs.fontSize);
+ const ad = fontAD(
+ cs.fontFamily,
+ parseInt(cs.fontWeight) >= 600,
+ cs.fontStyle.includes('italic')
+ );
+ const got = r.top + ad.a * F;
+ const src =
+ li < para.lines.length
+ ? para.lines[li].y
+ : para.lines[para.lines.length - 1].y;
+ const pageR = $('page').getBoundingClientRect();
+ const want = pageR.top + (P().pageHeight - src) * state.zoom;
+ const size = Math.max(...para.runs.map((q) => q.size || 12), 12);
+ return Math.max(0, got - want - Math.max(2, 0.16 * size * state.zoom));
+}
+
+function lineXDrift(es, para, runs) {
+ const pv = es?.preview;
+ if (!pv || !para?.lines) return null;
+ const pageR = $('page').getBoundingClientRect();
+ const els = [...es.editable.querySelectorAll('.eline')];
+ if (els.length !== pv.lines.length || els.length !== para.lines.length)
+ return null;
+ const origins = pageLineOrigins(pv, runs || para.runs, para);
+ let worst = 0,
+ k = -1,
+ seen = 0,
+ loose = 0;
+ els.forEach((d, i) => {
+ if (origins[i] === null) {
+ const el = para.lines[i];
+ if (typeof el.px === 'number' && Math.abs(el.px - pv.lines[i].x) > 0.5)
+ loose++;
+ return;
+ }
+ const first = d.firstElementChild || d;
+ const r = first.getBoundingClientRect();
+ if (!r.width && !r.height) return;
+ seen++;
+ const want = pageR.left + origins[i] * state.zoom;
+ const dv = Math.abs(r.left - want);
+ if (dv > worst) {
+ worst = dv;
+ k = i;
+ }
+ });
+ return seen || loose ? { worst, k, loose } : null;
+}
+
+function glyphDrift(es, para) {
+ glyphDrift.why = '';
+ if (!es?.editable || !para) {
+ glyphDrift.why = 'no editor';
+ return null;
+ }
+ const M = P().M;
+ let chars;
+ try {
+ const ptr = M._ec_test_pagetext(P().session, P().page);
+ chars = JSON.parse(M.UTF8ToString(ptr));
+ M._ec_string_free(ptr);
+ } catch (e) {
+ glyphDrift.why = 'pagetext: ' + e.message;
+ return null;
+ }
+ const pageVis = chars.filter((c) => !/\s/.test(String.fromCodePoint(c[0])));
+ const pageR = $('page').getBoundingClientRect();
+ const lines = [...es.editable.querySelectorAll('.eline')];
+ let worst = 0,
+ worstCh = '',
+ measured = 0;
+ let dbgShort = 0,
+ dbgNoMatch = 0,
+ dbgNoRect = 0;
+ for (const ln of lines) {
+ const walker = document.createTreeWalker(ln, NodeFilter.SHOW_TEXT);
+ let flat = '';
+ const map = [];
+ for (let n = walker.nextNode(); n; n = walker.nextNode())
+ for (let k = 0; k < n.textContent.length; k++) {
+ flat += n.textContent[k];
+ map.push([n, k]);
+ }
+ const vis = [];
+ for (let i = 0; i < flat.length; i++)
+ if (!/[\s\u200B\uFFFC]/.test(flat[i])) vis.push(i);
+ if (vis.length < 6) {
+ dbgShort++;
+ continue;
+ }
+ const take = vis.slice(0, 24);
+ const codes = take.map((i) => flat.codePointAt(i));
+ const rectOf = (i) => {
+ const [nd, k] = map[i];
+ const rg = document.createRange();
+ rg.setStart(nd, k);
+ rg.setEnd(nd, k + 1);
+ const r = rg.getBoundingClientRect();
+ return r.width || r.height ? (r.left - pageR.left) / state.zoom : null;
+ };
+ const x0 = rectOf(take[0]);
+ if (x0 === null) {
+ dbgNoRect++;
+ continue;
+ }
+ let at = -1,
+ bestD = Infinity;
+ outer: for (let i = 0; i + codes.length <= pageVis.length; i++) {
+ for (let j = 0; j < codes.length; j++)
+ if (pageVis[i + j][0] !== codes[j]) continue outer;
+ const d = Math.abs(pageVis[i][2] / 10 - x0);
+ if (d < bestD) {
+ bestD = d;
+ at = i;
+ }
+ }
+ if (at < 0 || bestD > 3) {
+ dbgNoMatch++;
+ continue;
+ }
+ for (let j = 0; j < codes.length; j++) {
+ const got = rectOf(take[j]);
+ if (got === null) continue;
+ measured++;
+ const d = Math.abs(got - pageVis[at + j][2] / 10);
+ if (d > worst) {
+ worst = d;
+ worstCh = String.fromCodePoint(codes[j]);
+ }
+ }
+ }
+ glyphDrift.why = `lines=${lines.length} short=${dbgShort} nomatch=${dbgNoMatch} norect=${dbgNoRect}`;
+ return measured ? { worst, ch: worstCh, n: measured } : null;
+}
+
+function metricKeys(runs) {
+ const out = [];
+ for (const r of runs) {
+ const key =
+ (r.family || '') +
+ '|' +
+ (r.size || 0) +
+ '|' +
+ (r.bold === 2 ? 2 : r.bold ? 1 : 0) +
+ '|' +
+ (r.italic === 2 ? 2 : r.italic ? 1 : 0) +
+ '|' +
+ (r.script | 0) +
+ '|' +
+ (r.hScale || 1) +
+ '|' +
+ (r.rise || 0) +
+ '|' +
+ (r.atomic ? 1 : 0);
+ for (let i = 0; i < (r.text || '').length; i++) out.push(key);
+ }
+ return out;
+}
+
+function pageLineOrigins(pv, runs, para) {
+ const n = pv.lines.length;
+ const none = new Array(n).fill(null);
+ if (!para || !para.lines || para.lines.length !== n) return none;
+ const pageText = para.runs.map((r) => r.text).join('');
+ const liveText = runs.map((r) => r.text).join('');
+ const pageKeys = metricKeys(para.runs);
+ const liveKeys = metricKeys(runs);
+ const out = none;
+ for (let k = 0; k < n; k++) {
+ const el = para.lines[k],
+ pl = pv.lines[k];
+ if (typeof el.px !== 'number' || el.off !== pl.flat) continue;
+ const a0 = el.off,
+ a1 = k + 1 < n ? para.lines[k + 1].off : pageText.length;
+ const b0 = pl.flat;
+ if (b0 < 0 || a1 > pageText.length) continue;
+ let b1 = -1;
+ for (let j = k + 1; j < n && b1 < 0; j++)
+ if (pv.lines[j].flat >= 0) b1 = pv.lines[j].flat;
+ if (b1 < 0) b1 = k + 1 < n ? b0 + (a1 - a0) : liveText.length;
+ if (a1 - a0 !== b1 - b0 || b1 > liveText.length) continue;
+ if (pageText.slice(a0, a1) !== liveText.slice(b0, b1)) continue;
+ let same = true;
+ for (let i = 0; i < a1 - a0 && same; i++)
+ same = pageKeys[a0 + i] === liveKeys[b0 + i];
+ if (same) out[k] = el.px;
+ }
+ return out;
+}
+
+let lockedEpoch = 0;
+
+function lockedRunSig(r) {
+ return [
+ Number.isInteger(r.sourceIndex) ? r.sourceIndex : '',
+ r.family,
+ r.fallback || '',
+ r.size,
+ (r.rgba ?? 0) >>> 0,
+ r.bold === 2 ? 2 : r.bold ? 1 : 0,
+ r.italic === 2 ? 2 : r.italic ? 1 : 0,
+ r.underline ? 1 : 0,
+ r.strike ? 1 : 0,
+ r.script | 0,
+ r.renderMode | 0,
+ (r.strokeRgba || 0) >>> 0,
+ r.strokeWidth ?? 1,
+ r.hScale ?? 1,
+ r.rise ?? 0,
+ r.atomic ? 1 : 0,
+ r.atomic && r.box ? r.box.slice(0, 5).join(',') : '',
+ (r.atomic && r.obj) || '',
+ ].join('');
+}
+
+function lockedMutatedLines(ed) {
+ let obs = ed._ecObs;
+ if (!obs) {
+ obs = new MutationObserver(() => {});
+ obs.observe(ed, {
+ subtree: true,
+ childList: true,
+ characterData: true,
+ attributes: true,
+ });
+ ed._ecObs = obs;
+ return true;
+ }
+ const recs = obs.takeRecords();
+ const marked = new Set();
+ for (const rec of recs) {
+ let node = rec.target;
+ while (node && node !== ed && node.parentNode !== ed)
+ node = node.parentNode;
+ if (!node || node === ed) return true;
+ marked.add(node);
+ }
+ return marked;
+}
+
+const LOCK_CHECK =
+ typeof location !== 'undefined' && /[?&]lockcheck/.test(location.search);
+
+let lockedSeq = 0;
+
+function renderLockedLines(ed, runs, pv, para, originX) {
+ lockedSeq++;
+ const dirty = lockedMutatedLines(ed);
+ const z = state.zoom;
+ const org = typeof originX === 'number' ? originX : pv.x;
+ const n = pv.lines.length;
+ ed.dataset.locked = '1';
+ ed.style.whiteSpace = 'pre';
+ ed.style.textAlign = 'left';
+ const slices = lockedSlices(pv, runs);
+ const useExt =
+ para &&
+ para.lines?.length === n &&
+ n > 0 &&
+ Math.abs(para.lines[0].y - pv.lines[0].baseline) <
+ 1.6 * (pv.lines[0].size || 12);
+ const blOf = (k) => (useExt ? para.lines[k].y : pv.lines[k].baseline);
+ const pageOrigin = pageLineOrigins(pv, runs, para);
+ const xOf = (k) => {
+ const px = pageOrigin[k];
+ return px !== null && px - org > -2 ? px : pv.lines[k].x;
+ };
+ const runSigs = runs.map(lockedRunSig);
+ const dpr = window.devicePixelRatio || 1;
+ const chSp = state.editing?.para?.format?.charSpacing || 0;
+ if (ed.childNodes.length !== ed.children.length) ed.textContent = '';
+ const geom = [];
+ for (let k = 0; k < n; k++) {
+ let A = 0,
+ maxF = 0;
+ for (const sg of slices[k].segs) {
+ const r1 = runs[sg.src] || {};
+ const F1 = (r1.size || pv.lines[k].size || 12) * z;
+ const docFam = docFontsReady.get(docFontKey(r1));
+ const stack =
+ (docFam ? `"${docFam}", ` : '') + WEB_FALLBACK(r1.family, r1.fallback);
+ const eb = docFam
+ ? r1.bold === 2
+ : r1.bold === 1 || r1.bold === true || r1.bold === 2;
+ const ei = docFam
+ ? r1.italic === 2
+ : r1.italic === 1 || r1.italic === true || r1.italic === 2;
+ const ad1 = fontAD(stack, eb, ei);
+ A = Math.max(A, (ad1.a - ad1.d) * F1);
+ maxF = Math.max(maxF, F1);
+ }
+ if (!(A > 0)) {
+ const F0 = (pv.lines[k].size || 12) * z;
+ A = 0.62 * F0;
+ maxF = F0;
+ }
+ geom.push({ A, maxF });
+ }
+ const buildLine = (k, t) => {
+ const { maxF, A } = geom[k];
+ const div = document.createElement('div');
+ div.className = 'eline';
+ const T = (pv.top - blOf(k)) * z;
+ const H = Math.max(4, maxF * 1.2);
+ const boxTop = T - (H + A) / 2;
+ div.style.height = H + 'px';
+ div.style.lineHeight = H + 'px';
+ div.style.marginTop = boxTop - t + 'px';
+ div.style.fontSize = '0px';
+ div.style.marginLeft = (xOf(k) - org) * z + 'px';
+ div.style.whiteSpace = 'pre';
+ if (slices[k].nl) div.dataset.nl = '1';
+ const exact = buildLockedLineExact(
+ div,
+ slices[k].segs,
+ pv.lines[k].cx,
+ runs,
+ z
+ );
+ for (let si = 0; !exact && si < slices[k].segs.length; si++) {
+ const seg = slices[k].segs[si];
+ const isAt = !!runs[seg.src]?.atomic;
+ const anchor = () => {
+ const a = document.createElement('span');
+ a.style.fontSize = (runs[seg.src]?.size || 12) * state.zoom + 'px';
+ a.appendChild(document.createTextNode('\u200B'));
+ return a;
+ };
+ if (isAt && si === 0) div.appendChild(anchor());
+ div.appendChild(runSpan({ ...runs[seg.src], text: seg.text }, seg.src));
+ if (isAt) div.appendChild(anchor());
+ }
+ if (!exact && !slices[k].segs.length)
+ div.appendChild(document.createTextNode(''));
+ if (LOCK_CHECK) {
+ div._ecDbg = {
+ seq: lockedSeq,
+ segTxt: slices[k].segs.map((s) => s.src + ':' + s.text).join('\u00b6'),
+ cxLen: Array.isArray(pv.lines[k].cx) ? pv.lines[k].cx.length : -1,
+ };
+ }
+ return div;
+ };
+ let t = 0;
+ const sigs = [];
+ const reusedNow = [];
+ for (let k = 0; k < n; k++) {
+ const { maxF, A } = geom[k];
+ const T = (pv.top - blOf(k)) * z;
+ const H = Math.max(4, maxF * 1.2);
+ const boxTop = T - (H + A) / 2;
+ let hasAtomic = false;
+ let segSig = '';
+ let lineTxt = '';
+ for (const sg of slices[k].segs) {
+ if (runs[sg.src]?.atomic) hasAtomic = true;
+ lineTxt += sg.text;
+ segSig += sg.src + '' + runSigs[sg.src] + '' + sg.text + '';
+ }
+ const cx = pv.lines[k].cx;
+ const sig =
+ lockedEpoch +
+ '|' +
+ z +
+ '|' +
+ dpr +
+ '|' +
+ chSp +
+ '|' +
+ H +
+ '|' +
+ (boxTop - t) +
+ '|' +
+ (xOf(k) - org) * z +
+ '|' +
+ (slices[k].nl ? 1 : 0) +
+ '|' +
+ (pv.lines[k].size || 0) +
+ '|' +
+ segSig +
+ '|' +
+ (Array.isArray(cx) ? cx.join(',') : '');
+ sigs.push(hasAtomic ? null : sig);
+ const prev = ed.children[k];
+ if (
+ !hasAtomic &&
+ prev &&
+ dirty !== true &&
+ !dirty.has(prev) &&
+ prev._ecSig === sig
+ ) {
+ if (prev.textContent === lineTxt) {
+ window.__lockReuse = (window.__lockReuse || 0) + 1;
+ reusedNow.push(true);
+ t = boxTop + H;
+ continue;
+ }
+ window.__lockTextGuard = (window.__lockTextGuard || 0) + 1;
+ if (LOCK_CHECK) {
+ (window.__lockGuardLog ||= []).push({
+ k,
+ seq: lockedSeq,
+ builtSeq: prev._ecDbg?.seq,
+ gotTail: prev.textContent.slice(-50),
+ wantTail: lineTxt.slice(-50),
+ dbgTxtEqNow: prev._ecDbg
+ ? prev._ecDbg.segTxt ===
+ slices[k].segs.map((s) => s.src + ':' + s.text).join('\u00b6')
+ : null,
+ });
+ }
+ }
+ reusedNow.push(false);
+ const div = buildLine(k, t);
+ div._ecSig = hasAtomic ? null : sig;
+ if (prev) ed.replaceChild(div, prev);
+ else ed.appendChild(div);
+ t = boxTop + H;
+ }
+ while (ed.children.length > n) ed.lastElementChild.remove();
+ if (LOCK_CHECK) {
+ window.__lockChecks = (window.__lockChecks || 0) + 1;
+ let rt = 0;
+ let bad = 0;
+ for (let k = 0; k < n; k++) {
+ const { maxF, A } = geom[k];
+ const T = (pv.top - blOf(k)) * z;
+ const H = Math.max(4, maxF * 1.2);
+ const boxTop = T - (H + A) / 2;
+ const ref = buildLine(k, rt);
+ const got = ed.children[k];
+ if (!got || got.outerHTML !== ref.outerHTML) {
+ bad++;
+ window.__lockBad = (window.__lockBad || 0) + 1;
+ const a = got ? got.outerHTML : '';
+ const b = ref.outerHTML;
+ let d = 0;
+ while (d < a.length && d < b.length && a[d] === b[d]) d++;
+ (window.__lockLog ||= []).push({
+ k,
+ epoch: lockedEpoch,
+ diffAt: d,
+ reusedNow: reusedNow[k],
+ sigNowEqStored: got ? got._ecSig === sigs[k] : null,
+ dirtyState: dirty === true ? 'all' : dirty.size,
+ refAgain: buildLine(k, rt).outerHTML === b,
+ seqNow: lockedSeq,
+ gotDbg: got?._ecDbg || null,
+ nowSegTxt: slices[k].segs
+ .map((s) => s.src + ':' + s.text)
+ .join('\u00b6'),
+ nowCxLen: Array.isArray(pv.lines[k].cx) ? pv.lines[k].cx.length : -1,
+ gotChildren: got ? got.children.length : -1,
+ wantChildren: ref.children.length,
+ got: a.slice(Math.max(0, d - 60), d + 160),
+ want: b.slice(Math.max(0, d - 60), d + 160),
+ });
+ if (got) ed.replaceChild(ref, got);
+ else ed.appendChild(ref);
+ }
+ rt = boxTop + H;
+ }
+ if (bad) console.warn('lockcheck: ' + bad + ' line(s) diverged');
+ }
+ ed._ecObs?.takeRecords();
+ const es0 = state.editing;
+ if (document.fonts?.ready && es0 && es0.editable === ed && !es0.fontsRefit) {
+ es0.fontsRefit = true;
+ document.fonts.ready.then(() => {
+ const es = state.editing;
+ if (!es || es.editable !== ed || !es.locked || !es.para) return;
+ _fontADCache.clear();
+ lockedEpoch++;
+ schedulePreview();
+ });
+ }
+}
+
+function attachParaLongPress(div, para) {
+ div.addEventListener('contextmenu', (e) => e.preventDefault());
+ div.addEventListener(
+ 'touchstart',
+ (e) => {
+ if (e.touches.length !== 1) return;
+ if (state.editing?.para?.id === para.id) return;
+ const sx = e.touches[0].clientX;
+ const sy = e.touches[0].clientY;
+ let fired = false;
+ const slop = (ev) => {
+ const t1 = ev.touches[0];
+ if (fired || !t1) return;
+ if (Math.hypot(t1.clientX - sx, t1.clientY - sy) > 12) cleanup();
+ };
+ const drive = (ev) => {
+ if (!fired) return;
+ ev.preventDefault();
+ const t1 = ev.touches[0];
+ if (!t1) return;
+ window.dispatchEvent(
+ new MouseEvent('mousemove', {
+ clientX: t1.clientX,
+ clientY: t1.clientY,
+ buttons: 1,
+ })
+ );
+ };
+ const finish = (ev) => {
+ if (fired) {
+ const t1 = ev.changedTouches[0];
+ window.dispatchEvent(
+ new MouseEvent('mouseup', {
+ clientX: t1 ? t1.clientX : sx,
+ clientY: t1 ? t1.clientY : sy,
+ })
+ );
+ } else if (ev.type === 'touchend') {
+ ev.preventDefault();
+ const t1 = ev.changedTouches[0];
+ beginEdit(para, {
+ x: t1 ? t1.clientX : sx,
+ y: t1 ? t1.clientY : sy,
+ });
+ }
+ cleanup();
+ };
+ const timer = setTimeout(() => {
+ fired = true;
+ navigator.vibrate?.(12);
+ if (state.editing) endEdit(true);
+ state.selection = { kind: 'para', para };
+ updateChrome();
+ drawOverlay();
+ const sel = $('overlay').querySelector('.sel-para');
+ if (sel) {
+ sel.dispatchEvent(
+ new MouseEvent('mousedown', {
+ bubbles: true,
+ clientX: sx,
+ clientY: sy,
+ buttons: 1,
+ })
+ );
+ }
+ }, 450);
+ const cleanup = () => {
+ clearTimeout(timer);
+ div.removeEventListener('touchmove', slop);
+ div.removeEventListener('touchmove', drive);
+ div.removeEventListener('touchend', finish);
+ div.removeEventListener('touchcancel', finish);
+ };
+ div.addEventListener('touchmove', slop, { passive: true });
+ div.addEventListener('touchmove', drive, { passive: false });
+ div.addEventListener('touchend', finish);
+ div.addEventListener('touchcancel', finish);
+ },
+ { passive: true }
+ );
+}
+
+function paraIsRtl(para) {
+ if (!para) return false;
+ if (para.format?.dir === 2) return true;
+ if (para.format?.dir === 1) return false;
+ const t = para.runs.map((r) => r.text).join('');
+ let rtl = 0,
+ ltr = 0;
+ for (const ch of t) {
+ const c = ch.codePointAt(0);
+ if (
+ (c >= 0x0590 && c <= 0x08ff) ||
+ (c >= 0xfb1d && c <= 0xfdff) ||
+ (c >= 0xfe70 && c <= 0xfefc)
+ )
+ rtl++;
+ else if (
+ (c >= 0x41 && c <= 0x5a) ||
+ (c >= 0x61 && c <= 0x7a) ||
+ (c >= 0x00c0 && c <= 0x024f) ||
+ (c >= 0x0370 && c <= 0x04ff)
+ )
+ ltr++;
+ }
+ return rtl > 0 && rtl >= ltr;
+}
+
+function lockedPointOffset(ed, container, contOffset) {
+ let off = 0,
+ found = -1;
+ const zw = (t) => t.split('\u200B').length - 1;
+ const walk = (node) => {
+ if (found >= 0) return;
+ if (node.nodeType === Node.TEXT_NODE) {
+ const t = node.textContent;
+ if (node === container) {
+ found = off + contOffset - zw(t.slice(0, contOffset));
+ return;
+ }
+ off += t.length - zw(t);
+ return;
+ }
+ if (node === container) {
+ let acc = off;
+ for (let i = 0; i < contOffset && i < node.childNodes.length; i++) {
+ const t = node.childNodes[i].textContent;
+ acc += t.length - (t.split('\u200B').length - 1);
+ }
+ found = acc;
+ return;
+ }
+ node.childNodes.forEach(walk);
+ if (node.classList?.contains('eline') && node.dataset.nl === '1') off += 1;
+ };
+ walk(ed);
+ return found;
+}
+
+function lockedCaretGet(ed) {
+ const sel = window.getSelection();
+ if (!sel.rangeCount) return -1;
+ const r = sel.getRangeAt(0);
+ if (!ed.contains(r.startContainer)) return -1;
+ return lockedPointOffset(ed, r.startContainer, r.startOffset);
+}
+
+function lockedSelRange(ed) {
+ const sel = window.getSelection();
+ if (!sel.rangeCount || sel.isCollapsed) return null;
+ const r = sel.getRangeAt(0);
+ if (!ed.contains(r.startContainer) || !ed.contains(r.endContainer))
+ return null;
+ const a = lockedPointOffset(ed, r.startContainer, r.startOffset);
+ const b = lockedPointOffset(ed, r.endContainer, r.endOffset);
+ if (a < 0 || b < 0 || a === b) return null;
+ return { start: Math.min(a, b), end: Math.max(a, b), locked: true };
+}
+
+function lockedLocate(ed, target) {
+ let off = 0,
+ hit = null;
+ const walk = (node) => {
+ if (hit) return;
+ if (node.nodeType === Node.TEXT_NODE) {
+ const t = node.textContent;
+ const len = t.length - (t.split('\u200B').length - 1);
+ if (target <= off + len) {
+ hit = { node, local: Math.max(0, target - off) };
+ return;
+ }
+ off += len;
+ return;
+ }
+ node.childNodes.forEach(walk);
+ if (hit) return;
+ if (node.classList?.contains('eline') && node.dataset.nl === '1') {
+ off += 1;
+ if (target <= off) {
+ const nxt = node.nextSibling;
+ const t = nxt && (nxt.firstChild?.firstChild || nxt.firstChild || nxt);
+ if (t)
+ hit =
+ t.nodeType === Node.TEXT_NODE
+ ? { node: t, local: 0 }
+ : { node: nxt, local: 0 };
+ else hit = { node, local: node.childNodes.length };
+ }
+ }
+ };
+ walk(ed);
+ if (hit && hit.node.parentElement?.dataset?.atomic === '1') {
+ const span = hit.node.parentElement;
+ const sib = hit.local === 0 ? span.previousSibling : span.nextSibling;
+ const txt =
+ sib &&
+ (sib.nodeType === Node.TEXT_NODE
+ ? sib
+ : sib.nodeType === 1 && sib.firstChild?.nodeType === Node.TEXT_NODE
+ ? sib.firstChild
+ : null);
+ if (txt)
+ hit = { node: txt, local: hit.local === 0 ? txt.textContent.length : 0 };
+ }
+ return hit;
+}
+
+function lockedCaretSet(ed, target) {
+ if (target < 0) return;
+ const sel = window.getSelection();
+ const at = lockedLocate(ed, target);
+ const r = document.createRange();
+ if (at) r.setStart(at.node, at.local);
+ else {
+ r.selectNodeContents(ed);
+ r.collapse(false);
+ sel.removeAllRanges();
+ sel.addRange(r);
+ return;
+ }
+ r.collapse(true);
+ sel.removeAllRanges();
+ sel.addRange(r);
+}
+
+function lockedSelSet(ed, start, end) {
+ const a = lockedLocate(ed, start),
+ b = lockedLocate(ed, end);
+ if (!a || !b) {
+ lockedCaretSet(ed, end);
+ return;
+ }
+ const sel = window.getSelection();
+ const r = document.createRange();
+ r.setStart(a.node, a.local);
+ r.setEnd(b.node, b.local);
+ sel.removeAllRanges();
+ sel.addRange(r);
+}
+
+let previewRaf = 0;
+function schedulePreview() {
+ const es = state.editing;
+ if (!es || !es.locked || !es.para) return;
+ cancelAnimationFrame(previewRaf);
+ previewRaf = requestAnimationFrame(() => {
+ const es2 = state.editing;
+ if (!es2 || !es2.locked || !es2.para) return;
+ if (es2.composing) {
+ const edC = es2.editable;
+ const runsC = parseEditor(edC, es2.para.runs);
+ if (!runsC.length) return;
+ const pvC = P().previewParagraph(
+ es2.para.id,
+ runsToInput(runsC),
+ es2.para.format
+ );
+ if (!pvC || !pvC.lines.length) return;
+ inPreviewPass = true;
+ try {
+ es2.preview = pvC;
+ applyLockedGeom(es2, pvC);
+ updateGlassSurface(es2, pvC, runsC);
+ drawOverlay();
+ } finally {
+ inPreviewPass = false;
+ }
+ return;
+ }
+ const ed = es2.editable;
+ const runs = parseEditor(ed, es2.para.runs);
+ if (!runs.length) return;
+ const pv = P().previewParagraph(
+ es2.para.id,
+ runsToInput(runs),
+ es2.para.format
+ );
+ if (!pv || !pv.lines.length) return;
+ inPreviewPass = true;
+ try {
+ const hadFocus =
+ ed === document.activeElement || ed.contains(document.activeElement);
+ const selR = lockedSelRange(ed);
+ const caret = lockedCaretGet(ed);
+ renderLockedLines(ed, runs, pv, es2.para, pv.x);
+ applyDocFonts(es2.para, ed);
+ if (hadFocus && selR) lockedSelSet(ed, selR.start, selR.end);
+ else if (hadFocus) lockedCaretSet(ed, caret);
+ es2.preview = pv;
+ applyLockedGeom(es2, pv);
+ updateGlassSurface(es2, pv, runs);
+ drawOverlay();
+ } finally {
+ inPreviewPass = false;
+ }
+ });
+}
+
+const GLASS_CHECK =
+ typeof location !== 'undefined' &&
+ new URLSearchParams(location.search).has('glasscheck');
+
+function updateGlassSurface(es, pv, runs) {
+ const para = es.para;
+ const capable =
+ para &&
+ es.locked &&
+ !es.pristine &&
+ !para.rotation &&
+ !para.vertical &&
+ !para.sharesObjects &&
+ !para.unwrapsForms &&
+ !es.glassOff;
+ if (!capable) {
+ es.glassLines = null;
+ if (es.glassCanvas) {
+ es.glassCanvas.remove();
+ es.glassCanvas = null;
+ }
+ if (!es.pristine) es.el.classList.remove('pristine');
+ es.glass = false;
+ return;
+ }
+ const z = state.zoom;
+ const scale = z * devicePixelRatio;
+ const padM = EDITOR_PAD / z;
+ const topDown = P().pageHeight - pv.top;
+ const fullX = pv.x - padM;
+ const fullY = topDown - padM;
+ const fullW = pv.width + 2 * padM;
+ const fullH = pv.height + 2 * padM;
+ const pxW = Math.round(fullW * scale);
+ const pxH = Math.round(fullH * scale);
+ let styleH = 19;
+ for (const r of runs || []) {
+ styleH = (styleH * 31 + ((r.rgba >>> 0) | 0)) | 0;
+ styleH = (styleH * 31 + (r.bold ? 2 : 1)) | 0;
+ styleH = (styleH * 31 + (r.italic ? 2 : 1)) | 0;
+ styleH = (styleH * 31 + (r.underline ? 2 : 1)) | 0;
+ styleH = (styleH * 31 + (r.strike ? 2 : 1)) | 0;
+ styleH = (styleH * 31 + ((r.script | 0) + 2)) | 0;
+ styleH = (styleH * 31 + ((r.renderMode | 0) + 2)) | 0;
+ styleH = (styleH * 31 + ((r.strokeRgba >>> 0) | 0)) | 0;
+ styleH = (styleH * 31 + Math.round((r.strokeWidth ?? 1) * 100)) | 0;
+ styleH = (styleH * 31 + Math.round((r.size ?? 0) * 100)) | 0;
+ for (let i = 0; i < (r.family || '').length; i++)
+ styleH = (styleH * 31 + r.family.charCodeAt(i)) | 0;
+ }
+
+ const keys = [];
+ let certain = true;
+ for (const ln of pv.lines) {
+ let h = styleH;
+ h = (h * 31 + Math.round(ln.baseline * 100)) | 0;
+ h = (h * 31 + Math.round((ln.x || 0) * 100)) | 0;
+ h = (h * 31 + Math.round((ln.size || 0) * 100)) | 0;
+ if (Array.isArray(ln.cx) && ln.cx.length) {
+ h = (h * 31 + ln.cx.length) | 0;
+ for (let i = 0; i < ln.cx.length; i++) {
+ h = (h * 31 + Math.round(ln.cx[i] * 50)) | 0;
+ }
+ } else {
+ certain = false;
+ }
+ keys.push(h);
+ }
+ const prev = es.glassLines;
+ const cv0 = es.glassCanvas;
+ const basePxX = Math.round(fullX * scale);
+ const basePxY = Math.round(fullY * scale);
+ const canPartial =
+ certain &&
+ prev &&
+ cv0 &&
+ cv0.isConnected &&
+ cv0.width === pxW &&
+ cv0.height === pxH &&
+ prev.length === keys.length &&
+ es.glassOrigin &&
+ es.glassOrigin[0] === basePxX &&
+ es.glassOrigin[1] === basePxY;
+ let r0 = 0;
+ let partial = false;
+ if (canPartial) {
+ let lo = -1,
+ hi = -1;
+ for (let k = 0; k < keys.length; k++) {
+ if (keys[k] !== prev[k]) {
+ if (lo < 0) lo = k;
+ hi = k;
+ }
+ }
+ if (lo < 0 && !GLASS_CHECK) {
+ es.glassLines = keys;
+ return;
+ }
+ if (lo < 0) {
+ lo = 0;
+ hi = 0;
+ }
+ const a = Math.max(0, lo - 1);
+ const b = Math.min(pv.lines.length - 1, hi + 1);
+ const lnA = pv.lines[a],
+ lnB = pv.lines[b];
+ const topPage = P().pageHeight - lnA.baseline - (lnA.size || 12) * 1.6;
+ const botPage = P().pageHeight - lnB.baseline + (lnB.size || 12) * 0.8;
+ const rr0 = Math.max(0, Math.floor((topPage - fullY) * scale));
+ const rr1 = Math.min(pxH, Math.ceil((botPage - fullY) * scale));
+ if (rr1 > rr0 && rr1 - rr0 < pxH * 0.7) {
+ partial = true;
+ r0 = rr0;
+ var bandH = rr1 - rr0;
+ }
+ }
+ let img;
+ const rpl0 = performance.now();
+ if (partial) {
+ img = P().renderParagraphLive(
+ para.id,
+ runsToInput(runs),
+ para.format,
+ scale,
+ basePxX / scale,
+ (basePxY + r0) / scale,
+ pxW / scale,
+ bandH / scale
+ );
+ } else {
+ img = P().renderParagraphLive(
+ para.id,
+ runsToInput(runs),
+ para.format,
+ scale,
+ basePxX / scale,
+ basePxY / scale,
+ pxW / scale,
+ pxH / scale
+ );
+ r0 = 0;
+ }
+ const rplDt = performance.now() - rpl0;
+ if (rplDt > 150) {
+ es.glassSlowN = (es.glassSlowN || 0) + 1;
+ if (es.glassSlowN >= 2) {
+ es.glassOff = true;
+ es.glassLines = null;
+ if (es.glassCanvas) {
+ es.glassCanvas.remove();
+ es.glassCanvas = null;
+ }
+ if (!es.pristine) es.el.classList.remove('pristine');
+ es.glass = false;
+ if (img) return;
+ }
+ } else {
+ es.glassSlowN = 0;
+ }
+ if (!img) {
+ es.glassLines = null;
+ if (es.glassCanvas) {
+ es.glassCanvas.remove();
+ es.glassCanvas = null;
+ }
+ if (!es.pristine) es.el.classList.remove('pristine');
+ es.glass = false;
+ return;
+ }
+ es.glass = true;
+ es.el.classList.add('pristine');
+ let cv = es.glassCanvas;
+ if (!cv) {
+ cv = document.createElement('canvas');
+ cv.className = 'glass-live';
+ Object.assign(cv.style, {
+ position: 'absolute',
+ left: '0',
+ top: '0',
+ pointerEvents: 'none',
+ zIndex: '0',
+ });
+ es.el.insertBefore(cv, es.el.firstChild);
+ es.glassCanvas = cv;
+ es.editable.style.position = 'relative';
+ es.editable.style.zIndex = '1';
+ }
+ if (cv.width !== pxW || cv.height !== pxH) {
+ if (partial) return;
+ cv.width = pxW;
+ cv.height = pxH;
+ }
+ cv.style.width = pxW / devicePixelRatio + 'px';
+ cv.style.height = pxH / devicePixelRatio + 'px';
+ cv.getContext('2d').putImageData(
+ new ImageData(img.data, img.width, img.height),
+ 0,
+ r0
+ );
+ es.glassLines = keys;
+ es.glassOrigin = [basePxX, basePxY];
+ clearTimeout(es.glassSettle);
+ if (partial) {
+ es.glassRuns = runs;
+ es.glassSettle = setTimeout(() => {
+ const cur = state.editing;
+ if (cur !== es || !cur.preview || !cur.glassRuns || !cur.locked) return;
+ cur.glassLines = null;
+ updateGlassSurface(cur, cur.preview, cur.glassRuns);
+ }, 250);
+ }
+ if (GLASS_CHECK) {
+ const ref = P().renderParagraphLive(
+ para.id,
+ runsToInput(runs),
+ para.format,
+ scale,
+ basePxX / scale,
+ basePxY / scale,
+ pxW / scale,
+ pxH / scale
+ );
+ if (ref) {
+ const cur = cv.getContext('2d').getImageData(0, 0, pxW, pxH).data;
+ let bad = 0;
+ let bx0 = 1e9,
+ by0 = 1e9,
+ bx1 = -1,
+ by1 = -1;
+ for (let i = 0; i < cur.length; i++) {
+ if (cur[i] !== ref.data[i]) {
+ bad++;
+ const pxI = (i >> 2) % pxW,
+ pyI = ((i >> 2) / pxW) | 0;
+ if (pxI < bx0) bx0 = pxI;
+ if (pxI > bx1) bx1 = pxI;
+ if (pyI < by0) by0 = pyI;
+ if (pyI > by1) by1 = pyI;
+ }
+ }
+ window.__glassChecks = (window.__glassChecks || 0) + 1;
+ window.__glassPartials =
+ (window.__glassPartials || 0) + (partial ? 1 : 0);
+ window.__glassBad = Math.max(window.__glassBad || 0, bad);
+ (window.__glassLog ||= []).push({
+ partial,
+ r0,
+ bandH: partial ? bandH : pxH,
+ pxW,
+ pxH,
+ bad,
+ badBox: bad ? [bx0, by0, bx1, by1] : null,
+ lines: pv.lines.length,
+ why: partial
+ ? ''
+ : !certain
+ ? 'no-cx'
+ : !prev
+ ? 'no-prev'
+ : !cv0 || !cv0.isConnected
+ ? 'no-canvas'
+ : cv0.width !== pxW || cv0.height !== pxH
+ ? 'dims'
+ : prev.length !== keys.length
+ ? 'line-count'
+ : !es.glassOrigin ||
+ es.glassOrigin[0] !== basePxX ||
+ es.glassOrigin[1] !== basePxY
+ ? 'origin'
+ : 'band-too-big',
+ });
+ if (bad && !window.__glassBadShot) {
+ const mk = (data) => {
+ const c = document.createElement('canvas');
+ c.width = pxW;
+ c.height = pxH;
+ c.getContext('2d').putImageData(new ImageData(data, pxW, pxH), 0, 0);
+ return c.toDataURL();
+ };
+ window.__glassBadShot = {
+ cur: mk(new Uint8ClampedArray(cur)),
+ ref: mk(ref.data),
+ key: window.__glassChecks,
+ };
+ }
+ if (bad) {
+ cv.getContext('2d').putImageData(
+ new ImageData(ref.data, ref.width, ref.height),
+ 0,
+ 0
+ );
+ }
+ }
+ }
+}
+
+function applyLockedGeom(es, pv) {
+ if (!es || !pv) return;
+ es.preview = pv;
+ const pseudo = {
+ ...es.para,
+ box: { x: pv.x, top: pv.top, w: pv.width, h: pv.height },
+ };
+ positionWrap(es.el, pseudo);
+ es.el.style.width = pv.width * state.zoom + EDITOR_PAD * 2 + 'px';
+ es.el.style.minHeight = pv.height * state.zoom + EDITOR_PAD * 2 + 'px';
+ const need = es.editable.scrollWidth + EDITOR_PAD * 2 + 1;
+ if (need > parseFloat(es.el.style.width)) es.el.style.width = need + 'px';
+ es.el.scrollLeft = 0;
+ es.editable.scrollLeft = 0;
+}
+
+function beginEdit(para, caret) {
+ openEditor({ para }, caret);
+}
+function beginNewTextBox(x, yTop, size, caret, width) {
+ const w = Number.isFinite(width) && width >= 30 ? width : 300;
+ openEditor({ newGeom: { x, yTop, width: w, size } }, caret);
+}
+
+function runSpan(run, srcIndex) {
+ const s = document.createElement('span');
+ const parts = (run.text || '').split('\n');
+ parts.forEach((part, i) => {
+ if (i > 0) s.appendChild(document.createElement('br'));
+ if (part) s.appendChild(document.createTextNode(part));
+ });
+ s.dataset.src = Number.isInteger(run.sourceIndex)
+ ? run.sourceIndex
+ : srcIndex;
+ s.dataset.family = run.family;
+ s.dataset.size = run.size;
+ s.dataset.rgba = run.rgba >>> 0;
+ s.dataset.bold = run.bold === 2 ? 2 : run.bold ? 1 : 0;
+ s.dataset.italic = run.italic === 2 ? 2 : run.italic ? 1 : 0;
+ s.dataset.underline = run.underline ? 1 : 0;
+ s.dataset.strike = run.strike ? 1 : 0;
+ s.dataset.script = run.script | 0;
+ s.dataset.renderMode = run.renderMode | 0;
+ s.dataset.strokeRgba = (run.strokeRgba || 0) >>> 0;
+ s.dataset.strokeWidth = run.strokeWidth ?? 1;
+ s.dataset.hScale = run.hScale ?? 1;
+ s.dataset.rise = run.rise ?? 0;
+ s.dataset.fallback = run.fallback || '';
+ const dfam = docFontsReady.get(docFontKey(run));
+ if (dfam) {
+ s.dataset.docfont = dfam;
+ s.dataset.docfontFor = run.family;
+ s.dataset.docfontB = run.bold === 2 ? '2' : run.bold ? '1' : '0';
+ s.dataset.docfontI = run.italic === 2 ? '2' : run.italic ? '1' : '0';
+ }
+ applySpanStyle(s);
+ if (run.atomic) {
+ s.dataset.atomic = '1';
+ if (run.box) {
+ s.dataset.atomicW = run.box[2];
+ s.dataset.abox = run.box
+ .slice(0, 5)
+ .map((v) => +(+v).toFixed(2))
+ .join(',');
+ }
+ if (run.obj) s.dataset.aobj = run.obj;
+ s.textContent = '\u2060';
+ applyAtomicSpanStyle(s);
+ }
+ return s;
+}
+
+function buildAtomicSnips(es, para) {
+ if (!es || para.rotation) return;
+ const atoms = (para.runs || []).filter((r) => r.atomic && r.box?.length >= 5);
+ if (!atoms.length) return;
+ const scale = state.zoom * devicePixelRatio;
+ const H = P().pageHeight;
+ let x0 = Infinity,
+ y0 = Infinity,
+ x1 = -Infinity,
+ y1 = -Infinity;
+ for (const r of atoms) {
+ const [x, top, w, h] = r.box;
+ x0 = Math.min(x0, x);
+ y0 = Math.min(y0, H - top);
+ x1 = Math.max(x1, x + w);
+ y1 = Math.max(y1, H - top + h);
+ }
+ const fullW = Math.max(1, Math.round(P().pageWidth * scale));
+ const fullH = Math.max(1, Math.round(H * scale));
+ const px0 = Math.max(0, Math.floor(x0 * scale) - 2);
+ const py0 = Math.max(0, Math.floor(y0 * scale) - 2);
+ const px1 = Math.min(fullW, Math.ceil(x1 * scale) + 2);
+ const py1 = Math.min(fullH, Math.ceil(y1 * scale) + 2);
+ const pw = px1 - px0,
+ ph = py1 - py0;
+ if (pw <= 0 || ph <= 0 || pw > 8192 || ph > 8192) return;
+ let img;
+ try {
+ img = P().renderPageRegion(fullW, fullH, px0, py0, pw, ph);
+ } catch {
+ return;
+ }
+ if (!img) return;
+ const full = document.createElement('canvas');
+ full.width = pw;
+ full.height = ph;
+ full.getContext('2d').putImageData(new ImageData(img.data, pw, ph), 0, 0);
+ es.snips = new Map();
+ for (const r of atoms) {
+ const key = r.box
+ .slice(0, 5)
+ .map((v) => +(+v).toFixed(2))
+ .join(',');
+ if (es.snips.has(key)) continue;
+ const [x, top, w, h] = r.box;
+ const sx = Math.floor(x * scale) - px0,
+ sy = Math.floor((H - top) * scale) - py0;
+ const sw = Math.max(1, Math.ceil(w * scale)),
+ sh = Math.max(1, Math.ceil(h * scale));
+ if (sw > 4000 || sh > 4000) continue;
+ const c = document.createElement('canvas');
+ c.width = sw;
+ c.height = sh;
+ c.getContext('2d').drawImage(full, sx, sy, sw, sh, 0, 0, sw, sh);
+ es.snips.set(key, c.toDataURL());
+ }
+}
+
+function applyAtomicSpanStyle(s) {
+ s.contentEditable = 'false';
+ s.style.color = 'transparent';
+ s.style.webkitTextFillColor = 'transparent';
+ s.style.textDecoration = 'none';
+ s.style.display = 'inline-block';
+ s.style.overflow = 'hidden';
+ const z = state.zoom;
+ const box = (s.dataset.abox || '').split(',').map(Number);
+ const snip = state.editing?.snips?.get(s.dataset.abox);
+ if (box.length >= 5 && snip) {
+ const [, top, w, h, baseline] = box;
+ s.style.width = w * z + 'px';
+ s.style.height = h * z + 'px';
+ s.style.backgroundImage = `url(${snip})`;
+ s.style.backgroundSize = '100% 100%';
+ s.style.backgroundRepeat = 'no-repeat';
+ s.style.verticalAlign = (top - h - baseline) * z + 'px';
+ return;
+ }
+ s.style.background = 'none';
+ s.style.verticalAlign = 'baseline';
+ const w = parseFloat(s.dataset.atomicW);
+ if (w > 0) s.style.width = w * z + 'px';
+}
+function applySpanStyle(s) {
+ const z = state.zoom;
+ const size = parseFloat(s.dataset.size);
+ const script = parseInt(s.dataset.script) || 0;
+ const vis = script !== 0 ? size * 0.58 : size;
+ const useDoc =
+ s.dataset.docfont &&
+ s.dataset.docfontFor === s.dataset.family &&
+ (s.dataset.docfontB ?? '0') ===
+ (s.dataset.bold === '2' ? '2' : s.dataset.bold === '1' ? '1' : '0') &&
+ (s.dataset.docfontI ?? '0') ===
+ (s.dataset.italic === '2' ? '2' : s.dataset.italic === '1' ? '1' : '0');
+ s.style.fontFamily = useDoc
+ ? `"${s.dataset.docfont}", ${WEB_FALLBACK(s.dataset.family, s.dataset.fallback)}`
+ : WEB_FALLBACK(s.dataset.family, s.dataset.fallback);
+ s.style.fontSize = vis * z + 'px';
+ s.style.fontWeight = (
+ useDoc
+ ? s.dataset.bold === '2'
+ : s.dataset.bold === '1' || s.dataset.bold === '2'
+ )
+ ? '700'
+ : '400';
+ s.style.fontStyle = (
+ useDoc
+ ? s.dataset.italic === '2'
+ : s.dataset.italic === '1' || s.dataset.italic === '2'
+ )
+ ? 'italic'
+ : 'normal';
+ s.style.color = rgbaToCss(parseInt(s.dataset.rgba) >>> 0);
+ const deco = [];
+ if (s.dataset.underline === '1') deco.push('underline');
+ if (s.dataset.strike === '1') deco.push('line-through');
+ s.style.textDecoration = deco.join(' ') || 'none';
+ s.style.verticalAlign =
+ script > 0 ? 'super' : script < 0 ? 'sub' : 'baseline';
+ const rm = parseInt(s.dataset.renderMode) || 0;
+ const sw = parseFloat(s.dataset.strokeWidth) || 0;
+ if ((rm === 1 || rm === 2) && sw > 0) {
+ s.style.webkitTextStroke = `${Math.min(3, sw * z * 0.5)}px ${rgbaToCss(parseInt(s.dataset.strokeRgba) >>> 0)}`;
+ if (rm === 1) s.style.webkitTextFillColor = 'transparent';
+ } else {
+ s.style.webkitTextStroke = '';
+ s.style.webkitTextFillColor = '';
+ }
+ const hs = parseFloat(s.dataset.hScale) || 1;
+ s.style.display = 'inline-block';
+ s.style.transform = hs !== 1 ? `scaleX(${hs})` : '';
+ s.style.transformOrigin = '0 50%';
+ if (hs === 1) s.style.display = '';
+}
+
+function parseEditor(ed, original) {
+ const runs = [];
+ const pushRun = (text, span) => {
+ if (text) text = text.replace(/[\r\u200B]/g, '');
+ if (!text) return;
+ const st = spanStyle(span, original);
+ const last = runs[runs.length - 1];
+ if (
+ last &&
+ sameStyle(last, st) &&
+ !last.text.endsWith('\n') &&
+ !text.startsWith('\n') &&
+ !last.text.includes('\uFFFC') &&
+ !text.includes('\uFFFC')
+ )
+ last.text += text;
+ else runs.push({ ...st, text });
+ };
+ const isBlock = (n) =>
+ n.nodeName === 'DIV' || n.nodeName === 'P' || n.nodeName === 'LI';
+ let lastSpan = null;
+ const collect = (node, curSpan) => {
+ node.childNodes.forEach((c) => {
+ if (c.nodeType === Node.TEXT_NODE) {
+ if (curSpan) lastSpan = curSpan;
+ pushRun(c.textContent, curSpan);
+ } else if (c.nodeName === 'BR') {
+ pushRun('\n', curSpan || lastSpan);
+ } else {
+ if (c.dataset?.atomic === '1') {
+ pushRun('\uFFFC', c);
+ const ar = runs[runs.length - 1];
+ if (ar && ar.text === '\uFFFC') {
+ ar.atomic = true;
+ ar.box = c.dataset.abox
+ ? c.dataset.abox.split(',').map(Number)
+ : [0, 0, parseFloat(c.dataset.atomicW) || 6, 10];
+ if (c.dataset.aobj) ar.obj = c.dataset.aobj;
+ }
+ lastSpan = c;
+ return;
+ }
+ const next =
+ c.dataset && c.dataset.src != null
+ ? c
+ : c.closest?.('span[data-src]') || curSpan;
+ const softLine = c.classList?.contains('eline');
+ if (
+ isBlock(c) &&
+ !softLine &&
+ runs.length &&
+ !runs[runs.length - 1].text.endsWith('\n')
+ )
+ pushRun('\n', curSpan || lastSpan);
+ collect(c, next);
+ if (softLine && c.dataset.nl === '1') pushRun('\n', lastSpan);
+ }
+ });
+ };
+ collect(ed, null);
+ if (runs.length > 1 && runs[runs.length - 1].text === '\n')
+ runs[runs.length - 2].text += runs.pop().text;
+ return runs.filter((r) => r.text.length > 0);
+}
+
+function spanStyle(span, original) {
+ if (span && span.dataset && span.dataset.src != null) {
+ const d = span.dataset;
+ return {
+ family: d.family,
+ size: parseFloat(d.size),
+ rgba: parseInt(d.rgba) >>> 0,
+ bold: d.bold === '2' ? 2 : d.bold === '1',
+ italic: d.italic === '2' ? 2 : d.italic === '1',
+ underline: d.underline === '1',
+ strike: d.strike === '1',
+ script: parseInt(d.script) || 0,
+ renderMode: parseInt(d.renderMode) || 0,
+ strokeRgba: (parseInt(d.strokeRgba) || 0) >>> 0,
+ strokeWidth: parseFloat(d.strokeWidth) || 1,
+ hScale: parseFloat(d.hScale) || 1,
+ rise: parseFloat(d.rise) || 0,
+ sourceIndex: parseInt(d.src),
+ };
+ }
+ const base = original.find((r) => !r.atomic && !r.text?.includes('')) || {
+ family: 'Helvetica',
+ size: original[0]?.size || 12,
+ rgba: 255,
+ };
+ return {
+ family: base.family,
+ size: base.size,
+ rgba: base.rgba >>> 0,
+ bold: !!base.bold,
+ italic: !!base.italic,
+ underline: false,
+ strike: false,
+ script: 0,
+ renderMode: 0,
+ strokeRgba: 0,
+ strokeWidth: 1,
+ hScale: 1,
+ rise: 0,
+ sourceIndex: original.indexOf(base),
+ };
+}
+function sameStyle(a, b) {
+ return (
+ a.family === b.family &&
+ a.size === b.size &&
+ a.rgba === b.rgba &&
+ a.bold === b.bold &&
+ a.italic === b.italic &&
+ a.underline === b.underline &&
+ a.strike === b.strike &&
+ a.script === b.script &&
+ a.sourceIndex === b.sourceIndex &&
+ (a.renderMode | 0) === (b.renderMode | 0) &&
+ (a.strokeRgba || 0) >>> 0 === (b.strokeRgba || 0) >>> 0 &&
+ Math.abs((a.strokeWidth ?? 1) - (b.strokeWidth ?? 1)) < 0.05 &&
+ Math.abs((a.hScale ?? 1) - (b.hScale ?? 1)) < 0.005 &&
+ Math.abs((a.rise ?? 0) - (b.rise ?? 0)) < 0.05
+ );
+}
+
+function endEdit(commit) {
+ const e = state.editing;
+ if (!e) return;
+ state.editing = null;
+ liveBlockMove = null;
+ clearTimeout(e.glassSettle);
+ P()?.renderParagraphLiveEnd?.();
+ e.el?.remove();
+ if (commit) {
+ if (e.newGeom) {
+ const runs = parseEditor(e.editable, []);
+ if (runs.some((r) => r.text.trim())) {
+ snapshotEdit('delete text', e.para);
+ if (e.newGeom.replaces != null) {
+ P().deleteParagraph(e.newGeom.replaces);
+ state.paragraphs = state.paragraphs.filter(
+ (q) => q.id !== e.newGeom.replaces
+ );
+ }
+ const created = P().addParagraph(
+ e.newGeom.x,
+ e.newGeom.yTop,
+ e.newGeom.width,
+ runs,
+ e.newGeom.fmt || {
+ align: 0,
+ lineSpacing: 1.25,
+ charSpacing: 0,
+ paraSpacing: 0,
+ }
+ );
+ if (created) {
+ state.paragraphs.push(created);
+ state.selection = { kind: 'para', para: created };
+ state.dirty = true;
+ }
+ } else {
+ state.selection = null;
+ }
+ } else {
+ const runs = parseEditor(e.editable, e.para.runs);
+ if (runs.length && runsDiffer(runs, e.para.runs)) {
+ if (window.__sanctityProbe)
+ (window.__sanctityWhy ||= []).push(runsDiffer.why);
+ snapshotEdit('edit text', e.para);
+ const prevBottomEdit = e.para.box
+ ? e.para.box.top - e.para.box.h
+ : -1e30;
+ const updated = P().commitParagraph(e.para.id, runs, e.para.format);
+ if (updated) {
+ replaceParagraph(e.para.id, updated);
+ cascadeParagraphGrowth(updated, prevBottomEdit);
+ state.selection = { kind: 'para', para: updated };
+ state.dirty = true;
+ if (!e.para.sharesObjects && !e.para.unwrapsForms) {
+ const pvBox = e.preview
+ ? {
+ box: {
+ x: e.preview.x,
+ top: e.preview.top,
+ w: e.preview.width,
+ h: e.preview.height,
+ },
+ }
+ : null;
+ refreshAfterMutation(
+ paraDirtyRect(blockMembers(e.para).concat([updated, pvBox]))
+ );
+ return;
+ }
+ } else {
+ state.undo.pop();
+ }
+ } else if (!e.para.sharesObjects && !e.para.unwrapsForms) {
+ const pvBox = e.preview
+ ? {
+ box: {
+ x: e.preview.x,
+ top: e.preview.top,
+ w: e.preview.width,
+ h: e.preview.height,
+ },
+ }
+ : null;
+ refreshAfterMutation(
+ paraDirtyRect(blockMembers(e.para).concat([pvBox]))
+ );
+ return;
+ }
+ }
+ }
+ refreshAfterMutation();
+}
+
+function updateHoverCursor(e) {
+ const canvas = $('page');
+ if (!P().doc) {
+ canvas.style.cursor = 'default';
+ return;
+ }
+ if (e.target.closest?.('.obj-handle') || e.target.closest?.('.edit-move'))
+ return;
+ if (state.tool === 'addText') {
+ canvas.style.cursor = 'text';
+ return;
+ }
+ const rect = canvas.getBoundingClientRect();
+ const px = (e.clientX - rect.left) / state.zoom,
+ py = P().pageHeight - (e.clientY - rect.top) / state.zoom;
+ canvas.style.cursor = hitTestParagraphStrong(px, py)
+ ? 'text'
+ : hitTestObject(px, py)
+ ? 'move'
+ : hitTestParagraph(px, py)
+ ? 'text'
+ : 'default';
+}
+
+function replaceParagraph(id, updated) {
+ const i = state.paragraphs.findIndex((p) => p.id === id);
+ if (i >= 0) {
+ if (updated) state.paragraphs[i] = updated;
+ else state.paragraphs.splice(i, 1);
+ } else if (updated) state.paragraphs.push(updated);
+}
+
+function runsDiffer(a, b) {
+ runsDiffer.why = null;
+ const lvl = (v) => (v === 2 ? 2 : v ? 1 : 0);
+ const same = (x, y) =>
+ x.family === y.family &&
+ Math.round(x.size) === Math.round(y.size) &&
+ x.rgba >>> 0 === y.rgba >>> 0 &&
+ lvl(x.bold) === lvl(y.bold) &&
+ lvl(x.italic) === lvl(y.italic) &&
+ !!x.underline === !!y.underline &&
+ !!x.strike === !!y.strike &&
+ (x.script | 0) === (y.script | 0) &&
+ (x.renderMode | 0) === (y.renderMode | 0) &&
+ (x.strokeRgba || 0) >>> 0 === (y.strokeRgba || 0) >>> 0 &&
+ Math.abs((x.strokeWidth ?? 1) - (y.strokeWidth ?? 1)) <= 0.05 &&
+ Math.abs((x.hScale ?? 1) - (y.hScale ?? 1)) <= 0.005 &&
+ Math.abs((x.rise ?? 0) - (y.rise ?? 0)) <= 0.05;
+ const flat = (list) => {
+ const out = [];
+ for (const r of list) {
+ const t = r.text || '';
+ for (let i = 0; i < t.length; i++) out.push({ ch: t[i], r });
+ }
+ return out;
+ };
+ const fa = flat(a),
+ fb = flat(b);
+ if (fa.length !== fb.length) {
+ runsDiffer.why = `length ${fb.length}→${fa.length}`;
+ return true;
+ }
+ const fields = [
+ ['text', (x, y, i) => fa[i].ch !== fb[i].ch],
+ ['family', (x, y) => x.family !== y.family],
+ ['size', (x, y) => Math.round(x.size) !== Math.round(y.size)],
+ ['rgba', (x, y) => x.rgba >>> 0 !== y.rgba >>> 0],
+ ['bold', (x, y) => lvl(x.bold) !== lvl(y.bold)],
+ ['italic', (x, y) => lvl(x.italic) !== lvl(y.italic)],
+ ['underline', (x, y) => !!x.underline !== !!y.underline],
+ ['strike', (x, y) => !!x.strike !== !!y.strike],
+ ['script', (x, y) => (x.script | 0) !== (y.script | 0)],
+ ['renderMode', (x, y) => (x.renderMode | 0) !== (y.renderMode | 0)],
+ [
+ 'strokeRgba',
+ (x, y) => (x.strokeRgba || 0) >>> 0 !== (y.strokeRgba || 0) >>> 0,
+ ],
+ [
+ 'strokeWidth',
+ (x, y) => Math.abs((x.strokeWidth ?? 1) - (y.strokeWidth ?? 1)) > 0.05,
+ ],
+ ['hScale', (x, y) => Math.abs((x.hScale ?? 1) - (y.hScale ?? 1)) > 0.005],
+ ['rise', (x, y) => Math.abs((x.rise ?? 0) - (y.rise ?? 0)) > 0.05],
+ ['atomic', (x, y) => !!x.atomic !== !!y.atomic],
+ ];
+ for (let i = 0; i < fa.length; i++) {
+ const x = fa[i].r,
+ y = fb[i].r;
+ for (const [name, differs] of fields) {
+ if (!differs(x, y, i)) continue;
+ const ctx = fb
+ .slice(Math.max(0, i - 8), i + 8)
+ .map((e) => e.ch)
+ .join('');
+ runsDiffer.why =
+ `char ${i} ${name}: ${JSON.stringify(y[name] ?? fb[i].ch)} → ` +
+ `${JSON.stringify(x[name] ?? fa[i].ch)} near ${JSON.stringify(ctx)}`;
+ return true;
+ }
+ }
+ return false;
+}
+
+function cascadeParagraphGrowth(updated, prevBottom) {
+ if (!updated?.box || !(prevBottom > -1e29)) return;
+ const newBottom = updated.box.top - updated.box.h;
+ const delta = prevBottom - newBottom;
+ if (!(Math.abs(delta) > 0.5)) return;
+ const L = updated.box.x;
+ const R = updated.box.x + updated.box.w;
+ const span = Math.max(1, R - L);
+ const movers = [];
+ for (const q of state.paragraphs) {
+ if (!q || q.id === updated.id || !q.box) continue;
+ if (!q.editable || q.vertical || q.invisible) continue;
+ if (Math.abs((q.rotation || 0) - (updated.rotation || 0)) > 0.01) continue;
+ if (q.box.top > prevBottom + 0.5) continue;
+ const l = q.box.x;
+ const r = q.box.x + q.box.w;
+ const overlap = Math.min(R, r) - Math.max(L, l);
+ if (overlap < 0.35 * Math.min(span, Math.max(1, r - l))) continue;
+ movers.push(q);
+ }
+ for (const q of movers) {
+ if (P().moveParagraph(q.id, 0, -delta)) {
+ const moved = {
+ ...q,
+ box: { ...q.box, top: q.box.top - delta },
+ };
+ replaceParagraph(q.id, moved);
+ }
+ }
+}
+
+function refreshAfterMutation(dirtyRect) {
+ if (P()._pageStale) {
+ refreshModel();
+ dirtyRect = null;
+ }
+ if (!dirtyRect || !renderPageRegionBlit(dirtyRect)) renderPage();
+ else {
+ drawOverlay();
+ drawRulers();
+ }
+ updateChrome();
+ scheduleRegen();
+}
+
+function renderPageRegionBlit(rect) {
+ const eng = P();
+ const canvas = $('page');
+ if (!eng?.doc || !canvas.width || !eng.pageWidth) return false;
+ if (state.editing) return false;
+ const scale = canvas.width / eng.pageWidth;
+ if (Math.abs(canvas.height / eng.pageHeight - scale) > 0.01) return false;
+ const pad = 8;
+ const px0 = Math.max(0, Math.floor((rect.x - pad) * scale));
+ const py0 = Math.max(0, Math.floor((rect.y - pad) * scale));
+ const px1 = Math.min(
+ canvas.width,
+ Math.ceil((rect.x + rect.w + pad) * scale)
+ );
+ const py1 = Math.min(
+ canvas.height,
+ Math.ceil((rect.y + rect.h + pad) * scale)
+ );
+ const pw = px1 - px0,
+ ph = py1 - py0;
+ if (pw <= 0 || ph <= 0) return false;
+ if (pw * ph > canvas.width * canvas.height * 0.6) return false;
+ const img = eng.renderPageRegion(
+ canvas.width,
+ canvas.height,
+ px0,
+ py0,
+ pw,
+ ph
+ );
+ if (!img) return false;
+ canvas
+ .getContext('2d')
+ .putImageData(new ImageData(img.data, img.width, img.height), px0, py0);
+ return true;
+}
+
+function blockMembers(para) {
+ if (!para?.blockId) return [para];
+ return state.paragraphs
+ .filter((q) => q.blockId === para.blockId)
+ .concat([para]);
+}
+
+function paraDirtyRect(entries) {
+ let x0 = Infinity,
+ y0 = Infinity,
+ x1 = -Infinity,
+ y1 = -Infinity;
+ const H = P().pageHeight;
+ for (const p of entries) {
+ if (!p?.box) continue;
+ const top = H - p.box.top;
+ x0 = Math.min(x0, p.box.x);
+ y0 = Math.min(y0, top);
+ x1 = Math.max(x1, p.box.x + p.box.w);
+ y1 = Math.max(y1, top + p.box.h);
+ }
+ if (!(x1 > x0) || !(y1 > y0)) return null;
+ return { x: x0, y: y0, w: x1 - x0, h: y1 - y0 };
+}
+
+let regenTimer = 0;
+function scheduleRegen() {
+ clearTimeout(regenTimer);
+}
+
+function editorSelectionRange(ed) {
+ const sel = window.getSelection();
+ if (!sel.rangeCount || sel.isCollapsed) return null;
+ const range = sel.getRangeAt(0);
+ if (!ed.contains(range.startContainer) || !ed.contains(range.endContainer))
+ return null;
+ const pre = document.createRange();
+ pre.selectNodeContents(ed);
+ pre.setEnd(range.startContainer, range.startOffset);
+ const start = pre.toString().length;
+ return { start, end: start + range.toString().length };
+}
+
+const WORD_CHAR = /[\p{L}\p{N}_'\u2019\uFFFD\uFFFC]/u;
+
+function isWordChar(ch) {
+ return !!ch && WORD_CHAR.test(ch);
+}
+
+function flattenEditorChars(ed) {
+ const chars = [];
+ const dsOf = (node) => {
+ const span =
+ node.nodeType === Node.TEXT_NODE
+ ? node.parentElement?.closest('span')
+ : node.closest?.('span');
+ const d = span?.dataset || {};
+ return {
+ src: d.src ?? '-1',
+ family: d.family || 'Helvetica',
+ size: d.size || '12',
+ rgba: d.rgba || '255',
+ bold: d.bold || '0',
+ italic: d.italic || '0',
+ underline: d.underline || '0',
+ strike: d.strike || '0',
+ script: d.script || '0',
+ renderMode: d.renderMode || '0',
+ strokeRgba: d.strokeRgba || '0',
+ strokeWidth: d.strokeWidth || '1',
+ hScale: d.hScale || '1',
+ rise: d.rise || '0',
+ fallback: d.fallback || '',
+ docfont: d.docfont || '',
+ docfontFor: d.docfontFor || '',
+ };
+ };
+ const isBlock = (n) =>
+ n.nodeName === 'DIV' || n.nodeName === 'P' || n.nodeName === 'LI';
+ const visit = (node) => {
+ for (const c of node.childNodes) {
+ if (c.nodeType === Node.TEXT_NODE) {
+ const base = dsOf(c);
+ for (let k = 0; k < c.textContent.length; k++)
+ chars.push({ ch: c.textContent[k], ds: { ...base } });
+ } else if (c.nodeName === 'BR') {
+ chars.push({ ch: '\n', ds: dsOf(c) });
+ } else {
+ if (isBlock(c) && chars.length && chars[chars.length - 1].ch !== '\n')
+ chars.push({ ch: '\n', ds: dsOf(c) });
+ visit(c);
+ }
+ }
+ };
+ visit(ed);
+ return chars;
+}
+
+const dsKey = (d) =>
+ `${d.src}|${d.family}|${d.size}|${d.rgba}|${d.bold}|${d.italic}|${d.underline}|${d.strike}|${d.script}|${d.renderMode}|${d.strokeRgba}|${d.strokeWidth}|${d.hScale}|${d.rise}`;
+
+function rebuildEditorFromChars(ed, chars) {
+ ed.replaceChildren();
+ let i = 0;
+ while (i < chars.length) {
+ const key = dsKey(chars[i].ds),
+ ds = chars[i].ds;
+ let text = '';
+ while (i < chars.length && dsKey(chars[i].ds) === key) {
+ text += chars[i].ch;
+ i++;
+ }
+ const span = document.createElement('span');
+ span.textContent = text;
+ Object.assign(span.dataset, ds);
+ applySpanStyle(span);
+ if (ds.atomic === '1') applyAtomicSpanStyle(span);
+ ed.appendChild(span);
+ }
+ if (!ed.childNodes.length) ed.appendChild(document.createElement('span'));
+}
+
+function restoreEditorSelection(ed, start, end) {
+ const sel = window.getSelection();
+ const range = document.createRange();
+ let count = 0,
+ sN,
+ sO,
+ eN,
+ eO;
+ const walk = (node) => {
+ for (const c of node.childNodes) {
+ if (c.nodeType === Node.TEXT_NODE) {
+ const len = c.textContent.length;
+ if (sN == null && start <= count + len) {
+ sN = c;
+ sO = start - count;
+ }
+ if (eN == null && end <= count + len) {
+ eN = c;
+ eO = end - count;
+ }
+ count += len;
+ } else if (c.nodeName === 'BR') {
+ count += 1;
+ } else walk(c);
+ }
+ };
+ walk(ed);
+ if (sN && eN) {
+ range.setStart(sN, sO);
+ range.setEnd(eN, eO);
+ sel.removeAllRanges();
+ sel.addRange(range);
+ }
+}
+
+function dsFromRun(r) {
+ return {
+ family: r.family,
+ size: String(r.size),
+ rgba: String(r.rgba >>> 0),
+ bold: r.bold === 2 ? '2' : r.bold ? '1' : '0',
+ italic: r.italic === 2 ? '2' : r.italic ? '1' : '0',
+ underline: r.underline ? '1' : '0',
+ strike: r.strike ? '1' : '0',
+ script: String(r.script | 0),
+ renderMode: String(r.renderMode | 0),
+ strokeRgba: String((r.strokeRgba || 0) >>> 0),
+ strokeWidth: String(r.strokeWidth ?? 1),
+ hScale: String(r.hScale ?? 1),
+ rise: String(r.rise ?? 0),
+ };
+}
+function applyDsToRun(r, d) {
+ r.family = d.family;
+ r.size = parseFloat(d.size);
+ r.rgba = parseInt(d.rgba) >>> 0;
+ r.bold =
+ d.bold === '2' || d.bold === 2
+ ? 2
+ : d.bold === '1' || d.bold === 1 || d.bold === true;
+ r.italic =
+ d.italic === '2' || d.italic === 2
+ ? 2
+ : d.italic === '1' || d.italic === 1 || d.italic === true;
+ r.underline = d.underline == 1 || d.underline === true;
+ r.strike = d.strike == 1 || d.strike === true;
+ r.script = parseInt(d.script) || 0;
+ r.renderMode = parseInt(d.renderMode) || 0;
+ r.strokeRgba = (parseInt(d.strokeRgba) || 0) >>> 0;
+ r.strokeWidth = parseFloat(d.strokeWidth) || 1;
+ r.hScale = parseFloat(d.hScale) || 1;
+ r.rise = parseFloat(d.rise) || 0;
+}
+
+function styleLockedRange(es, sr, mutate) {
+ const ed = es.editable;
+ const runs = parseEditor(ed, es.para.runs);
+ const out = [];
+ let at = 0;
+ for (const r of runs) {
+ const len = r.text.length;
+ const s0 = Math.max(at, sr.start),
+ e0 = Math.min(at + len, sr.end);
+ if (s0 >= e0 || r.text.includes('\uFFFC')) {
+ out.push(r);
+ } else {
+ const pre = r.text.slice(0, s0 - at);
+ const mid = r.text.slice(s0 - at, e0 - at);
+ const post = r.text.slice(e0 - at);
+ if (pre) out.push({ ...r, text: pre });
+ const m2 = { ...r, text: mid };
+ const d = dsFromRun(m2);
+ mutate(d);
+ applyDsToRun(m2, d);
+ out.push(m2);
+ if (post) out.push({ ...r, text: post });
+ }
+ at += len;
+ }
+ let pv = null;
+ try {
+ pv = P().previewParagraph(es.para.id, runsToInput(out), es.para.format);
+ } catch {}
+ if (pv && pv.lines.length) {
+ const hadFocus =
+ ed === document.activeElement || ed.contains(document.activeElement);
+ renderLockedLines(ed, out, pv, es.para);
+ applyDocFonts(es.para, ed);
+ es.preview = pv;
+ applyLockedGeom(es, pv);
+ updateGlassSurface(es, pv, out);
+ drawOverlay();
+ if (hadFocus) lockedSelSet(ed, sr.start, sr.end);
+ es.lastSel = { start: sr.start, end: sr.end, locked: true };
+ }
+}
+
+function lockedInsertBreak(es) {
+ const ed = es.editable;
+ let caret = lockedCaretGet(ed);
+ if (caret < 0) return;
+ unpristine();
+ const runs = parseEditor(ed, es.para.runs);
+ const out = [];
+ let at = 0,
+ inserted = false;
+ for (const r of runs) {
+ const len = r.text.length;
+ if (!inserted && caret <= at + len) {
+ if (r.text.includes('\uFFFC')) {
+ out.push(r);
+ if (caret < at + len) caret = at + len;
+ } else {
+ const k = caret - at;
+ const pre = r.text.slice(0, k),
+ post = r.text.slice(k);
+ if (pre) out.push({ ...r, text: pre });
+ out.push({ ...r, text: '\n' });
+ if (post) out.push({ ...r, text: post });
+ inserted = true;
+ }
+ } else {
+ out.push(r);
+ }
+ at += len;
+ }
+ if (!inserted) {
+ const last = runs[runs.length - 1];
+ out.push(last ? { ...last, text: '\n' } : { text: '\n' });
+ }
+ let pv = null;
+ try {
+ pv = P().previewParagraph(es.para.id, runsToInput(out), es.para.format);
+ } catch {}
+ if (!pv || !pv.lines.length) return;
+ renderLockedLines(ed, out, pv, es.para);
+ applyDocFonts(es.para, ed);
+ es.preview = pv;
+ applyLockedGeom(es, pv);
+ drawOverlay();
+ lockedCaretSet(ed, caret + 1);
+}
+
+function styleTargetRuns(mutate) {
+ if (state.editing) unpristine();
+ if (state.editing) {
+ const ed = state.editing.editable;
+ if (state.editing.locked) {
+ const srL =
+ lockedSelRange(ed) ||
+ (state.editing.lastSel?.locked ? state.editing.lastSel : null);
+ if (srL && srL.end > srL.start) {
+ styleLockedRange(state.editing, srL, mutate);
+ updateChrome();
+ return;
+ }
+ ed.querySelectorAll('span[data-src]').forEach((sp) => {
+ mutate(sp.dataset);
+ applySpanStyle(sp);
+ if (sp.dataset.atomic === '1') applyAtomicSpanStyle(sp);
+ });
+ schedulePreview();
+ updateChrome();
+ return;
+ }
+ const sr = editorSelectionRange(ed) || state.editing.lastSel;
+ if (sr && sr.end > sr.start) {
+ const hadFocus =
+ ed === document.activeElement || ed.contains(document.activeElement);
+ const chars = flattenEditorChars(ed);
+ for (let i = sr.start; i < sr.end && i < chars.length; i++)
+ mutate(chars[i].ds);
+ rebuildEditorFromChars(ed, chars);
+ if (hadFocus) restoreEditorSelection(ed, sr.start, sr.end);
+ state.editing.lastSel = { ...sr };
+ } else {
+ ed.querySelectorAll('span').forEach((s) => {
+ mutate(s.dataset);
+ applySpanStyle(s);
+ });
+ }
+ if (state.editing.locked) schedulePreview();
+ updateChrome();
+ return;
+ }
+ if (state.selection?.kind === 'para') {
+ const para = state.selection.para;
+ const runs = para.runs.map((r, i) => {
+ const d = {
+ family: r.family,
+ size: r.size,
+ rgba: r.rgba >>> 0,
+ bold: r.bold === 2 ? 2 : r.bold ? 1 : 0,
+ italic: r.italic === 2 ? 2 : r.italic ? 1 : 0,
+ underline: r.underline ? 1 : 0,
+ strike: r.strike ? 1 : 0,
+ script: r.script | 0,
+ hScale: r.hScale || 1,
+ renderMode: r.renderMode | 0,
+ strokeRgba: (r.strokeRgba || 0) >>> 0,
+ strokeWidth: r.strokeWidth ?? 1,
+ rise: r.rise || 0,
+ };
+ mutate(d);
+ return {
+ text: r.text,
+ family: d.family,
+ size: parseFloat(d.size),
+ rgba: d.rgba >>> 0,
+ bold: parseInt(d.bold) || 0,
+ italic: parseInt(d.italic) || 0,
+ underline: d.underline == 1,
+ strike: d.strike == 1,
+ script: parseInt(d.script) || 0,
+ renderMode: parseInt(d.renderMode) || 0,
+ strokeRgba: (parseInt(d.strokeRgba) || 0) >>> 0,
+ strokeWidth: parseFloat(d.strokeWidth) || 1,
+ hScale: parseFloat(d.hScale) || 1,
+ rise: parseFloat(d.rise) || 0,
+ sourceIndex: i,
+ };
+ });
+ snapshotEdit('edit text', para);
+ const updated = P().commitParagraph(para.id, runs, para.format);
+ if (updated) {
+ replaceParagraph(para.id, updated);
+ state.selection = { kind: 'para', para: updated };
+ state.dirty = true;
+ }
+ }
+ refreshAfterMutation();
+}
+
+function changeFormat(mutate) {
+ if (state.editing) unpristine();
+ if (state.editing?.newGeom) {
+ const fmt = { align: 0, lineSpacing: 1.25, charSpacing: 0, paraSpacing: 0 };
+ mutate(fmt);
+ state.editing.newGeom.fmt = fmt;
+ state.editing.editable.style.textAlign =
+ ['left', 'center', 'right', 'justify'][fmt.align] || 'left';
+ state.editing.editable.style.lineHeight = fmt.lineSpacing;
+ updateChrome();
+ return;
+ }
+ let para = state.editing?.para || state.selection?.para;
+ if (!para) return;
+ const fmt = { ...para.format };
+ mutate(fmt);
+ const runs = state.editing
+ ? parseEditor(state.editing.editable, para.runs)
+ : para.runs.map((r, i) => ({ ...r, rgba: r.rgba >>> 0, sourceIndex: i }));
+ const safe = runs.length
+ ? runs
+ : para.runs.map((r, i) => ({ ...r, rgba: r.rgba >>> 0, sourceIndex: i }));
+ snapshotEdit('edit text', para);
+ const prevBottomFmt = para.box ? para.box.top - para.box.h : -1e30;
+ const updated = P().commitParagraph(para.id, safe, fmt);
+ if (updated) {
+ replaceParagraph(updated.id, updated);
+ cascadeParagraphGrowth(updated, prevBottomFmt);
+ state.selection = { kind: 'para', para: updated };
+ if (state.editing) {
+ state.editing.para = updated;
+ const ed2 = state.editing.editable;
+ ed2.style.textAlign =
+ ['left', 'center', 'right', 'justify'][updated.format.align] || 'left';
+ if (!state.editing.locked)
+ ed2.style.lineHeight = updated.format.lineSpacing || 1.2;
+ if (state.editing.locked) schedulePreview();
+ }
+ state.dirty = true;
+ }
+ refreshAfterMutation();
+}
+
+function scopeAllowsPara() {
+ return state.editScope.includes('all') || state.editScope.includes('text');
+}
+function scopeAllowsObj(type) {
+ if (type === OBJ.UNKNOWN || type === OBJ.TEXT) return false;
+ if (state.editScope.includes('all')) return true;
+ if (state.editScope.includes('image') && type === OBJ.IMAGE) return true;
+ if (
+ state.editScope.includes('shape') &&
+ (type === OBJ.PATH || type === OBJ.FORM)
+ )
+ return true;
+ return false;
+}
+
+let objectIndexCache = null;
+
+function objectIndex() {
+ const eng = P();
+ const epoch = eng._objEpoch || 0;
+ if (
+ objectIndexCache &&
+ objectIndexCache.epoch === epoch &&
+ objectIndexCache.page === eng.pageIndex
+ )
+ return objectIndexCache.items;
+ const items = [];
+ const n = eng.objectCount();
+ for (let i = 0; i < n; i++) {
+ const o = eng.objectAt(i);
+ if (!o) continue;
+ const b = eng.objectBounds(o.handle);
+ if (!b) continue;
+ items.push({ handle: o.handle, index: i, type: o.type, bounds: b });
+ }
+ objectIndexCache = { epoch, page: eng.pageIndex, items };
+ return items;
+}
+
+function hitTestObject(px, py) {
+ let best = null;
+ for (const o of objectIndex()) {
+ if (!scopeAllowsObj(o.type)) continue;
+ const b = o.bounds;
+ if (px >= b.x && px <= b.x + b.w && py >= b.y && py <= b.y + b.h) best = o;
+ }
+ return best ? { ...best } : null;
+}
+
+function hitTestParagraph(px, py) {
+ if (!scopeAllowsPara()) return null;
+ let best = null,
+ bestArea = Infinity;
+ for (const p of state.paragraphs) {
+ if (!p.editable) continue;
+ const q = pageToText(p, px, py);
+ const x = p.box.x,
+ top = p.box.top,
+ w = p.box.w,
+ h = p.box.h;
+ if (
+ q.x >= x - 3 &&
+ q.x <= x + w + 3 &&
+ q.y <= top + 3 &&
+ q.y >= top - h - 3
+ ) {
+ const area = w * h;
+ if (area < bestArea) {
+ best = p;
+ bestArea = area;
+ }
+ }
+ }
+ return best;
+}
+
+function hitTestParagraphStrong(px, py) {
+ if (!scopeAllowsPara()) return null;
+ let best = null,
+ bestArea = Infinity;
+ for (const p of state.paragraphs) {
+ if (!p.editable || !p.lines?.length) continue;
+ const q = pageToText(p, px, py);
+ const sz = p.runs?.reduce((m, r) => Math.max(m, r.size || 0), 0) || 12;
+ for (const L of p.lines) {
+ if (
+ q.x >= L.x - 2 &&
+ q.x <= L.x + L.w + 2 &&
+ q.y >= L.y - 0.35 * sz &&
+ q.y <= L.y + 1.0 * sz
+ ) {
+ const area = p.box.w * p.box.h;
+ if (area < bestArea) {
+ best = p;
+ bestArea = area;
+ }
+ break;
+ }
+ }
+ }
+ return best;
+}
+
+function paraEnvelope(p) {
+ const b = p.box;
+ const cs = [
+ [b.x, b.top],
+ [b.x + b.w, b.top],
+ [b.x, b.top - b.h],
+ [b.x + b.w, b.top - b.h],
+ ].map(([x, y]) => textToPage(p, x, y));
+ const xs = cs.map((c) => c.x),
+ ys = cs.map((c) => c.y);
+ const x0 = Math.min(...xs),
+ y0 = Math.min(...ys);
+ return { x: x0, y: y0, w: Math.max(...xs) - x0, h: Math.max(...ys) - y0 };
+}
+
+function selectObject(o) {
+ state.selection = o ? { kind: 'object', ...o } : null;
+ drawOverlay();
+ updateChrome();
+}
+function selectParagraph(p) {
+ state.selection = p ? { kind: 'para', para: p } : null;
+ drawOverlay();
+ updateChrome();
+}
+
+const sameItem = (a, b) =>
+ a.t === b.t &&
+ (a.t === 'para' ? a.para.id === b.para.id : a.handle === b.handle);
+
+function currentItems() {
+ const s = state.selection;
+ if (!s) return [];
+ if (s.kind === 'multi') return [...s.items];
+ if (s.kind === 'para') return [{ t: 'para', para: s.para }];
+ if (s.kind === 'object')
+ return [{ t: 'obj', handle: s.handle, type: s.type, bounds: s.bounds }];
+ return [];
+}
+
+function setMultiSelection(items) {
+ endEdit(true);
+ if (items.length === 0) state.selection = null;
+ else if (items.length === 1) {
+ const it = items[0];
+ state.selection =
+ it.t === 'para'
+ ? { kind: 'para', para: it.para }
+ : {
+ kind: 'object',
+ handle: it.handle,
+ type: it.type,
+ bounds: it.bounds,
+ };
+ } else state.selection = { kind: 'multi', items };
+ drawOverlay();
+ updateChrome();
+}
+
+function toggleMultiSelect(item) {
+ endEdit(true);
+ const items = currentItems();
+ const i = items.findIndex((x) => sameItem(x, item));
+ if (i >= 0) items.splice(i, 1);
+ else items.push(item);
+ setMultiSelection(items);
+}
+
+function noteItemMatrix(it) {
+ if (it.t === 'obj') noteMatrix(it.handle);
+}
+
+function moveItem(it, dx, dy) {
+ if (it.t === 'obj') {
+ P().translateObject(it.handle, dx, dy);
+ it.bounds = P().objectBounds(it.handle);
+ } else if (P().moveParagraph(it.para.id, dx, dy)) {
+ const dT = pageToText(it.para, dx, dy);
+ const moved = {
+ ...it.para,
+ box: {
+ ...it.para.box,
+ x: it.para.box.x + dT.x,
+ top: it.para.box.top + dT.y,
+ },
+ };
+ replaceParagraph(it.para.id, moved);
+ it.para = moved;
+ }
+}
+
+function itemEnvelope(it) {
+ return it.t === 'obj' ? { ...it.bounds } : paraEnvelope(it.para);
+}
+
+function alignSelection(op) {
+ if (state.selection?.kind !== 'multi') return;
+ const items = state.selection.items;
+ const envs = items.map(itemEnvelope);
+ snapshotEdit('align');
+ for (const it of items) noteItemMatrix(it);
+ if (op === 'disth' || op === 'distv') {
+ if (items.length < 3) {
+ state.undo.pop();
+ return;
+ }
+ const horiz = op === 'disth';
+ const order = items
+ .map((it, i) => ({ it, e: envs[i] }))
+ .sort((a, b) =>
+ horiz ? a.e.x - b.e.x : b.e.y + b.e.h - (a.e.y + a.e.h)
+ );
+ const first = order[0].e,
+ last = order[order.length - 1].e;
+ const span = horiz ? last.x + last.w - first.x : first.y + first.h - last.y;
+ const sum = order.reduce((n, o) => n + (horiz ? o.e.w : o.e.h), 0);
+ const gap = (span - sum) / (order.length - 1);
+ let cursor = horiz ? first.x : first.y + first.h;
+ for (const o of order) {
+ if (horiz) {
+ moveItem(o.it, cursor - o.e.x, 0);
+ cursor += o.e.w + gap;
+ } else {
+ moveItem(o.it, 0, cursor - (o.e.y + o.e.h));
+ cursor -= o.e.h + gap;
+ }
+ }
+ } else {
+ let target;
+ if (op === 'left') target = Math.min(...envs.map((e) => e.x));
+ else if (op === 'right') target = Math.max(...envs.map((e) => e.x + e.w));
+ else if (op === 'hcenter')
+ target = envs.reduce((n, e) => n + e.x + e.w / 2, 0) / envs.length;
+ else if (op === 'top') target = Math.max(...envs.map((e) => e.y + e.h));
+ else if (op === 'bottom') target = Math.min(...envs.map((e) => e.y));
+ else if (op === 'vcenter')
+ target = envs.reduce((n, e) => n + e.y + e.h / 2, 0) / envs.length;
+ items.forEach((it, i) => {
+ const e = envs[i];
+ let dx = 0,
+ dy = 0;
+ if (op === 'left') dx = target - e.x;
+ else if (op === 'right') dx = target - (e.x + e.w);
+ else if (op === 'hcenter') dx = target - (e.x + e.w / 2);
+ else if (op === 'top') dy = target - (e.y + e.h);
+ else if (op === 'bottom') dy = target - e.y;
+ else if (op === 'vcenter') dy = target - (e.y + e.h / 2);
+ moveItem(it, dx, dy);
+ });
+ }
+ state.dirty = true;
+ refreshAfterMutation();
+}
+
+const LIST_RX =
+ /^\s*((\d{1,3})([.)])|([a-z]{1,4})([.)])|([A-Z]{1,4})([.)])|[•◦▪‣●○–—-])\s+/;
+
+const ROMAN = [
+ [1000, 'm'],
+ [900, 'cm'],
+ [500, 'd'],
+ [400, 'cd'],
+ [100, 'c'],
+ [90, 'xc'],
+ [50, 'l'],
+ [40, 'xl'],
+ [10, 'x'],
+ [9, 'ix'],
+ [5, 'v'],
+ [4, 'iv'],
+ [1, 'i'],
+];
+function romanToInt(str) {
+ const s2 = str.toLowerCase();
+ let i = 0,
+ v = 0;
+ for (const [val, sym] of ROMAN) {
+ while (s2.startsWith(sym, i)) {
+ v += val;
+ i += sym.length;
+ }
+ }
+ return i === s2.length && v > 0 ? v : null;
+}
+function intToRoman(n) {
+ let out = '';
+ for (const [val, sym] of ROMAN)
+ while (n >= val) {
+ out += sym;
+ n -= val;
+ }
+ return out;
+}
+function parseMarker(body) {
+ if (/^\d+$/.test(body)) return { kind: 'num', n: parseInt(body, 10) };
+ const roman = romanToInt(body);
+ if (roman != null && (body.length > 1 || body.toLowerCase() === 'i')) {
+ return {
+ kind: 'roman',
+ n: roman,
+ upper: body[0] === body[0].toUpperCase(),
+ };
+ }
+ if (/^[a-zA-Z]$/.test(body)) {
+ return {
+ kind: 'alpha',
+ n: body.toLowerCase().charCodeAt(0) - 96,
+ upper: body === body.toUpperCase(),
+ };
+ }
+ return null;
+}
+function markerText(kind, n, upper, sep) {
+ let core = '';
+ if (kind === 'num') core = String(n);
+ else if (kind === 'alpha')
+ core = String.fromCharCode(96 + Math.max(1, Math.min(26, n)));
+ else core = intToRoman(Math.max(1, n));
+ if (upper) core = core.toUpperCase();
+ return core + (sep || '.');
+}
+
+function listPrefixOf(para, ed) {
+ const t = ed
+ ? flattenEditorChars(ed)
+ .map((c) => c.ch)
+ .join('')
+ : para.runs[0]?.text || '';
+ const m = t.match(LIST_RX);
+ if (m) {
+ const body = m[2] || m[4] || m[6];
+ const sep = m[3] || m[5] || m[7] || null;
+ const seq = body ? parseMarker(body) : null;
+ return {
+ text: m[1] + ' ',
+ num: seq ? seq.n : null,
+ sep,
+ kind: seq ? seq.kind : null,
+ upper: seq ? !!seq.upper : false,
+ };
+ }
+ if (para.marker) return { text: '', num: null, marker: true };
+ return null;
+}
+
+function continueListItem(lp) {
+ const src = state.editing.para;
+ const style = { ...src.runs[0] };
+ endEdit(true);
+ const cur = state.paragraphs.find((q) => q.id === src.id) || src;
+ const size = style.size || 12;
+ const pitch = Math.max(1, cur.format.lineSpacing || 1.2) * size;
+ const yTop = cur.box.top - cur.box.h - Math.max(2, pitch - size);
+ const prefix = lp.marker
+ ? '\u00a0'
+ : lp.num != null
+ ? markerText(lp.kind || 'num', lp.num + 1, lp.upper, lp.sep) + ' '
+ : lp.text;
+ snapshotEdit('add text');
+ let created = P().addParagraph(
+ cur.box.x,
+ yTop,
+ cur.box.w,
+ [
+ {
+ text: prefix,
+ family: style.family,
+ size,
+ rgba: style.rgba >>> 0 || 255,
+ bold: false,
+ italic: false,
+ underline: false,
+ strike: false,
+ script: 0,
+ renderMode: 0,
+ strokeRgba: 0,
+ strokeWidth: 1,
+ hScale: 1,
+ rise: 0,
+ sourceIndex: -1,
+ },
+ ],
+ {
+ align: 0,
+ lineSpacing: cur.format.lineSpacing || 1.2,
+ charSpacing: 0,
+ paraSpacing: 0,
+ }
+ );
+ if (created) {
+ state.paragraphs.push(created);
+ if (lp.marker && !P().cloneMarker(cur.id, created.id)) {
+ const fb = P().commitParagraph(
+ created.id,
+ [
+ {
+ ...created.runs[0],
+ text: '• ',
+ rgba: created.runs[0].rgba >>> 0,
+ sourceIndex: -1,
+ },
+ ],
+ created.format
+ );
+ if (fb) {
+ replaceParagraph(created.id, fb);
+ created = fb;
+ }
+ }
+ if (lp.num != null) renumberListBelow(created, lp.num + 1);
+ state.dirty = true;
+ renderPage();
+ beginEdit(created);
+ }
+}
+
+function renumberListBelow(fromPara, fromNum) {
+ const sibs = state.paragraphs
+ .filter(
+ (q) =>
+ q.id !== fromPara.id &&
+ q.editable &&
+ Math.abs(q.box.x - fromPara.box.x) < 8 &&
+ q.box.top < fromPara.box.top
+ )
+ .sort((a, b) => b.box.top - a.box.top);
+ let expect = fromNum + 1;
+ for (const q of sibs) {
+ const m = (q.runs[0]?.text || '').match(LIST_RX);
+ if (!m) break;
+ const body = m[2] || m[4] || m[6];
+ const sep = m[3] || m[5] || m[7];
+ const seq = body ? parseMarker(body) : null;
+ if (!seq) break;
+ const runs = q.runs.map((r, i) => ({
+ ...r,
+ rgba: r.rgba >>> 0,
+ sourceIndex: i,
+ text:
+ i === 0
+ ? r.text.replace(
+ LIST_RX,
+ markerText(seq.kind, expect, seq.upper, sep) + ' '
+ )
+ : r.text,
+ }));
+ const u = P().commitParagraph(q.id, runs, q.format);
+ if (u) replaceParagraph(q.id, u);
+ expect++;
+ }
+}
+
+function setListMarkerStyle(styleKey) {
+ let para = state.editing?.para || state.selection?.para;
+ if (!para) return;
+ if (state.editing) endEdit(true);
+ para = state.paragraphs.find((q) => q.id === para.id) || para;
+ const kind =
+ styleKey === '1'
+ ? 'num'
+ : styleKey.toLowerCase() === 'i'
+ ? 'roman'
+ : 'alpha';
+ const upper = styleKey !== '1' && styleKey === styleKey.toUpperCase();
+ const group = state.paragraphs
+ .filter(
+ (q) =>
+ q.editable &&
+ Math.abs(q.box.x - para.box.x) < 8 &&
+ LIST_RX.test(q.runs[0]?.text || '')
+ )
+ .sort((a, b) => b.box.top - a.box.top);
+ if (!group.length) return;
+ snapshot();
+ let n = 0;
+ for (const q of group) {
+ const m = (q.runs[0]?.text || '').match(LIST_RX);
+ const body = m && (m[2] || m[4] || m[6]);
+ if (!body || !parseMarker(body)) continue;
+ n++;
+ const sep = m[3] || m[5] || m[7] || '.';
+ const runs = q.runs.map((r, i) => ({
+ ...r,
+ rgba: r.rgba >>> 0,
+ sourceIndex: i,
+ text:
+ i === 0
+ ? r.text.replace(LIST_RX, markerText(kind, n, upper, sep) + ' ')
+ : r.text,
+ }));
+ const u = P().commitParagraph(q.id, runs, q.format);
+ if (u) replaceParagraph(q.id, u);
+ }
+ state.dirty = n > 0;
+ refreshAfterMutation();
+}
+
+function toggleListPrefix(kind) {
+ let para = state.editing?.para || state.selection?.para;
+ if (!para) return;
+ if (state.editing) endEdit(true);
+ para = state.paragraphs.find((q) => q.id === para.id) || para;
+ const t = para.runs[0]?.text || '';
+ const m = t.match(LIST_RX);
+ let newFirst;
+ if (m && ((kind === 'bullet' && !m[2]) || (kind === 'number' && m[2]))) {
+ newFirst = t.replace(LIST_RX, '');
+ } else {
+ const stripped = m ? t.replace(LIST_RX, '') : t;
+ if (kind === 'bullet') newFirst = '• ' + stripped;
+ else {
+ const above = state.paragraphs
+ .filter(
+ (q) =>
+ q.id !== para.id &&
+ Math.abs(q.box.x - para.box.x) < 8 &&
+ q.box.top > para.box.top
+ )
+ .sort((a, b) => a.box.top - b.box.top)[0];
+ const am = above ? (above.runs[0]?.text || '').match(LIST_RX) : null;
+ const n = am && am[2] ? parseInt(am[2], 10) + 1 : 1;
+ newFirst = `${n}. ` + stripped;
+ }
+ }
+ const runs = para.runs.map((r, i) => ({
+ ...r,
+ rgba: r.rgba >>> 0,
+ sourceIndex: i,
+ text: i === 0 ? newFirst : r.text,
+ }));
+ snapshotEdit('edit text', para);
+ const u = P().commitParagraph(para.id, runs, para.format);
+ if (u) {
+ replaceParagraph(para.id, u);
+ state.selection = { kind: 'para', para: u };
+ state.dirty = true;
+ }
+ refreshAfterMutation();
+}
+
+function stagePointHandlers() {
+ const canvas = $('page');
+ const ov = $('overlay');
+ let drag = null;
+
+ window.addEventListener(
+ 'mousedown',
+ (e) => {
+ if (!state.editing) return;
+ const t = e.target;
+ if (
+ t.closest?.('.editor') ||
+ t.closest?.('#inspector') ||
+ t.closest?.('#toolbar') ||
+ t.closest?.('#findbar') ||
+ t.closest?.('#contextbar') ||
+ t.closest?.('.dockpanel') ||
+ t.closest?.('.dockrail') ||
+ t.closest?.('.dsheet') ||
+ t.closest?.('.para-box') ||
+ t.closest?.('.para-handle') ||
+ t.closest?.('.edit-move') ||
+ t.closest?.('.rot-handle')
+ )
+ return;
+ endEdit(true);
+ if (!t.closest?.('#pageWrap')) selectObject(null);
+ },
+ true
+ );
+
+ ov.addEventListener(
+ 'touchstart',
+ (e) => {
+ if (e.touches.length !== 1) return;
+ const t0 = e.touches[0];
+ const el = document
+ .elementFromPoint(t0.clientX, t0.clientY)
+ ?.closest?.(
+ '.obj-handle, .rot-handle, .para-handle, .edit-move, .sel-para, .obj-box'
+ );
+ if (!el) return;
+ e.preventDefault();
+ e.stopPropagation();
+ const sx = t0.clientX;
+ const sy = t0.clientY;
+ const tapPara =
+ el.classList.contains('sel-para') && state.selection?.kind === 'para'
+ ? state.selection.para
+ : null;
+ let fired = false;
+ const fire = () => {
+ fired = true;
+ el.dispatchEvent(
+ new MouseEvent('mousedown', {
+ bubbles: true,
+ cancelable: true,
+ clientX: sx,
+ clientY: sy,
+ buttons: 1,
+ })
+ );
+ };
+ if (!tapPara) fire();
+ const move = (ev) => {
+ const t1 = ev.touches[0];
+ if (!t1) return;
+ ev.preventDefault();
+ if (!fired) {
+ if (Math.hypot(t1.clientX - sx, t1.clientY - sy) <= 12) return;
+ fire();
+ }
+ window.dispatchEvent(
+ new MouseEvent('mousemove', {
+ clientX: t1.clientX,
+ clientY: t1.clientY,
+ buttons: 1,
+ })
+ );
+ };
+ const end = (ev) => {
+ const t1 = ev.changedTouches?.[0];
+ if (fired) {
+ window.dispatchEvent(
+ new MouseEvent('mouseup', {
+ clientX: t1 ? t1.clientX : sx,
+ clientY: t1 ? t1.clientY : sy,
+ })
+ );
+ } else if (ev.type === 'touchend') {
+ beginEdit(tapPara, {
+ x: t1 ? t1.clientX : sx,
+ y: t1 ? t1.clientY : sy,
+ });
+ }
+ ov.removeEventListener('touchmove', move);
+ ov.removeEventListener('touchend', end);
+ ov.removeEventListener('touchcancel', end);
+ };
+ ov.addEventListener('touchmove', move, { passive: false });
+ ov.addEventListener('touchend', end);
+ ov.addEventListener('touchcancel', end);
+ },
+ { passive: false }
+ );
+
+ ov.addEventListener('mousedown', (e) => {
+ const objH = e.target.closest?.(
+ '.obj-handle:not(.para-handle):not(.rot-handle)'
+ );
+ const parH = e.target.closest?.('.para-handle');
+ const mvH = e.target.closest?.('.edit-move');
+ const rotH = e.target.closest?.('.rot-handle');
+ const mbox = e.target.closest?.('.multi-box');
+ const { px, py } = toPage(e);
+ if (rotH && state.editing?.para) {
+ e.stopPropagation();
+ e.preventDefault();
+ const pid = state.editing.para.id;
+ endEdit(true);
+ const para = state.paragraphs.find((q) => q.id === pid);
+ if (!para) return;
+ state.selection = { kind: 'para', para };
+ drawOverlay();
+ updateChrome();
+ const b = para.box;
+ const c = textToPage(para, b.x + b.w / 2, b.top - b.h / 2);
+ drag = {
+ mode: 'prot',
+ para,
+ cx: c.x,
+ cy: c.y,
+ a0: Math.atan2(py - c.y, px - c.x),
+ delta: 0,
+ moved: false,
+ };
+ return;
+ }
+ if (rotH && state.selection?.kind === 'object') {
+ e.stopPropagation();
+ e.preventDefault();
+ const bb = state.selection.bounds;
+ drag = {
+ mode: 'orot',
+ handle: state.selection.handle,
+ cx: bb.x + bb.w / 2,
+ cy: bb.y + bb.h / 2,
+ a0: Math.atan2(py - (bb.y + bb.h / 2), px - (bb.x + bb.w / 2)),
+ delta: 0,
+ moved: false,
+ };
+ return;
+ }
+ if (rotH && state.selection?.kind === 'para') {
+ e.stopPropagation();
+ e.preventDefault();
+ const para = state.selection.para;
+ const b = para.box;
+ const c = textToPage(para, b.x + b.w / 2, b.top - b.h / 2);
+ drag = {
+ mode: 'prot',
+ para,
+ cx: c.x,
+ cy: c.y,
+ a0: Math.atan2(py - c.y, px - c.x),
+ delta: 0,
+ moved: false,
+ };
+ return;
+ }
+ if (mbox && state.selection?.kind === 'multi') {
+ e.stopPropagation();
+ const it = state.selection.items[+mbox.dataset.idx];
+ if (e.shiftKey) {
+ toggleMultiSelect(it);
+ return;
+ }
+ drag = {
+ mode: 'mmove',
+ startPx: px,
+ startPy: py,
+ moved: false,
+ lastDx: 0,
+ lastDy: 0,
+ };
+ return;
+ }
+ if (mvH && state.editing?.para) {
+ e.stopPropagation();
+ e.preventDefault();
+ drag = {
+ mode: 'emove',
+ b0: { ...state.editing.para.box },
+ blockId: state.editing.para.blockId || 0,
+ startPx: px,
+ startPy: py,
+ moved: false,
+ lastDx: 0,
+ lastDy: 0,
+ };
+ } else if (parH && (state.editing?.para || state.editing?.newGeom)) {
+ e.stopPropagation();
+ const g0 = state.editing.para
+ ? { ...state.editing.para.box }
+ : {
+ x: state.editing.newGeom.x,
+ w: state.editing.newGeom.width,
+ };
+ drag = {
+ mode: 'ewrap',
+ edge: parH.dataset.ewrap,
+ para: state.editing.para,
+ b0: g0,
+ startPx: px,
+ moved: false,
+ };
+ } else if (objH && state.selection?.kind === 'object') {
+ e.stopPropagation();
+ drag = {
+ mode: 'resize',
+ corner: objH.dataset.handle,
+ handle: state.selection.handle,
+ startPx: px,
+ startPy: py,
+ b0: { ...state.selection.bounds },
+ moved: false,
+ };
+ } else if (parH && state.selection?.kind === 'para') {
+ e.stopPropagation();
+ drag = {
+ mode: 'pwrap',
+ edge: parH.dataset.phandle,
+ para: state.selection.para,
+ b0: { ...state.selection.para.box },
+ startPx: px,
+ moved: false,
+ };
+ }
+ });
+
+ ov.addEventListener('mousedown', (e) => {
+ if (e.target.closest?.('.obj-handle')) return;
+ const { px, py } = toPage(e);
+ if (e.target.closest?.('.obj-box') && state.selection?.kind === 'object') {
+ e.stopPropagation();
+ drag = {
+ mode: 'move',
+ handle: state.selection.handle,
+ startPx: px,
+ startPy: py,
+ moved: false,
+ lastDx: 0,
+ lastDy: 0,
+ clientX: e.clientX,
+ clientY: e.clientY,
+ };
+ } else if (
+ e.target.closest?.('.sel-para') &&
+ state.selection?.kind === 'para'
+ ) {
+ e.stopPropagation();
+ drag = {
+ mode: 'pmove',
+ para: state.selection.para,
+ startPx: px,
+ startPy: py,
+ moved: false,
+ lastDx: 0,
+ lastDy: 0,
+ clientX: e.clientX,
+ clientY: e.clientY,
+ };
+ }
+ });
+
+ canvas.addEventListener(
+ 'touchstart',
+ (e) => {
+ if (e.touches.length !== 1 || state.editing) return;
+ const sx = e.touches[0].clientX;
+ const sy = e.touches[0].clientY;
+ let fired = false;
+ const slop = (ev) => {
+ const t1 = ev.touches[0];
+ if (fired || !t1) return;
+ if (Math.hypot(t1.clientX - sx, t1.clientY - sy) > 12) cleanup();
+ };
+ const drive = (ev) => {
+ if (!fired) return;
+ const t1 = ev.touches[0];
+ if (!t1) return;
+ ev.preventDefault();
+ window.dispatchEvent(
+ new MouseEvent('mousemove', {
+ clientX: t1.clientX,
+ clientY: t1.clientY,
+ buttons: 1,
+ })
+ );
+ };
+ const finish = (ev) => {
+ if (fired) {
+ const t1 = ev.changedTouches?.[0];
+ window.dispatchEvent(
+ new MouseEvent('mouseup', {
+ clientX: t1 ? t1.clientX : sx,
+ clientY: t1 ? t1.clientY : sy,
+ })
+ );
+ }
+ cleanup();
+ };
+ const timer = setTimeout(() => {
+ fired = true;
+ navigator.vibrate?.(12);
+ canvas.dispatchEvent(
+ new MouseEvent('mousedown', {
+ bubbles: true,
+ cancelable: true,
+ clientX: sx,
+ clientY: sy,
+ buttons: 1,
+ })
+ );
+ }, 450);
+ const cleanup = () => {
+ clearTimeout(timer);
+ canvas.removeEventListener('touchmove', slop);
+ canvas.removeEventListener('touchmove', drive);
+ canvas.removeEventListener('touchend', finish);
+ canvas.removeEventListener('touchcancel', finish);
+ };
+ canvas.addEventListener('touchmove', slop, { passive: true });
+ canvas.addEventListener('touchmove', drive, { passive: false });
+ canvas.addEventListener('touchend', finish);
+ canvas.addEventListener('touchcancel', finish);
+ },
+ { passive: true }
+ );
+
+ canvas.addEventListener('mousedown', (e) => {
+ if (state.editing) return;
+ const { px, py } = toPage(e);
+ if (state.tool === 'addText') {
+ drag = {
+ mode: 'newbox',
+ startPx: px,
+ startPy: py,
+ moved: false,
+ clientX: e.clientX,
+ clientY: e.clientY,
+ };
+ return;
+ }
+ const paraStrong = hitTestParagraphStrong(px, py);
+ if (paraStrong) {
+ if (e.shiftKey) {
+ toggleMultiSelect({ t: 'para', para: paraStrong });
+ return;
+ }
+ beginEdit(paraStrong, { x: e.clientX, y: e.clientY });
+ return;
+ }
+ const obj = hitTestObject(px, py);
+ if (obj) {
+ if (e.shiftKey) {
+ toggleMultiSelect({
+ t: 'obj',
+ handle: obj.handle,
+ type: obj.type,
+ bounds: obj.bounds,
+ });
+ return;
+ }
+ selectObject(obj);
+ drag = {
+ mode: 'move',
+ handle: obj.handle,
+ startPx: px,
+ startPy: py,
+ moved: false,
+ lastDx: 0,
+ lastDy: 0,
+ };
+ return;
+ }
+ const para = hitTestParagraph(px, py);
+ if (para) {
+ if (e.shiftKey) {
+ toggleMultiSelect({ t: 'para', para });
+ return;
+ }
+ beginEdit(para, { x: e.clientX, y: e.clientY });
+ return;
+ }
+ if (!e.altKey) clearTextSelection();
+ drag = {
+ mode: e.altKey ? 'textsel' : 'marquee',
+ startPx: px,
+ startPy: py,
+ moved: false,
+ };
+ });
+
+ window.addEventListener('mousemove', (e) => {
+ if (!drag) {
+ updateHoverCursor(e);
+ return;
+ }
+ const { px, py } = toPage(e);
+ drag.moved = true;
+ if (drag.mode === 'move') {
+ drag.lastDx = px - drag.startPx;
+ drag.lastDy = py - drag.startPy;
+ drag.smartT ||= collectSmartTargets({ skipObj: new Set([drag.handle]) });
+ {
+ const s2 = smartSnap(
+ state.selection.bounds,
+ drag.lastDx,
+ drag.lastDy,
+ drag.smartT
+ );
+ drag.lastDx = s2.dx;
+ drag.lastDy = s2.dy;
+ showSmartGuides(s2.gv, s2.gh);
+ }
+ const b = state.selection.bounds;
+ const r = objRectView({
+ ...b,
+ x: b.x + drag.lastDx,
+ y: b.y + drag.lastDy,
+ });
+ const box = ov.querySelector('.obj-box');
+ if (box) {
+ box.style.left = r.x - BOX_PAD + 'px';
+ box.style.top = r.y - BOX_PAD + 'px';
+ }
+ } else if (drag.mode === 'pmove') {
+ drag.lastDx = px - drag.startPx;
+ drag.lastDy = py - drag.startPy;
+ if (!drag.para.rotation) {
+ const b = drag.para.box;
+ const pc = textToPage(drag.para, b.x, b.top);
+ drag.smartT ||= collectSmartTargets({
+ skipPara: new Set([drag.para.id]),
+ });
+ const s2 = smartSnap(
+ { x: pc.x, y: pc.y - b.h, w: b.w, h: b.h },
+ drag.lastDx,
+ drag.lastDy,
+ drag.smartT
+ );
+ drag.lastDx = s2.dx;
+ drag.lastDy = s2.dy;
+ showSmartGuides(s2.gv, s2.gh);
+ }
+ const dT = pageToText(drag.para, drag.lastDx, drag.lastDy);
+ const tmp = {
+ ...drag.para,
+ box: {
+ ...drag.para.box,
+ x: drag.para.box.x + dT.x,
+ top: drag.para.box.top + dT.y,
+ },
+ };
+ const pl = paraPlacement(tmp);
+ const ch = ov.querySelector('.sel-chrome');
+ if (ch) {
+ ch.style.left = pl.x + 'px';
+ ch.style.top = pl.y + 'px';
+ }
+ } else if (drag.mode === 'pwrap') {
+ const dT = pageToText(drag.para, px - drag.startPx, py - drag.startPy);
+ const dx = dT.x;
+ let nx = drag.b0.x,
+ nw = drag.b0.w;
+ if (drag.edge === 'e') nw = Math.max(30, drag.b0.w + dx);
+ else {
+ nw = Math.max(30, drag.b0.w - dx);
+ nx = drag.b0.x + (drag.b0.w - nw);
+ }
+ drag.nw = nw;
+ drag.nx = nx;
+ if (!drag.para.rotation) {
+ const r = paraRectView({ ...drag.b0, x: nx, w: nw });
+ const ch = ov.querySelector('.sel-chrome');
+ if (ch) {
+ ch.style.left = r.x + 'px';
+ ch.style.width = r.w + 'px';
+ const bx = ch.querySelector('.sel-para');
+ if (bx) bx.style.width = r.w + 'px';
+ }
+ }
+ } else if (drag.mode === 'emove') {
+ drag.lastDx = px - drag.startPx;
+ drag.lastDy = py - drag.startPy;
+ {
+ const para = state.editing?.para;
+ if (para && !para.rotation) {
+ drag.smartT ||= collectSmartTargets({
+ skipPara: new Set(
+ drag.blockId
+ ? state.paragraphs
+ .filter((q) => q.blockId === drag.blockId)
+ .map((q) => q.id)
+ : [para.id]
+ ),
+ });
+ const b = drag.b0;
+ const s2 = smartSnap(
+ { x: b.x, y: b.top - b.h, w: b.w, h: b.h },
+ drag.lastDx,
+ drag.lastDy,
+ drag.smartT
+ );
+ drag.lastDx = s2.dx;
+ drag.lastDy = s2.dy;
+ showSmartGuides(s2.gv, s2.gh);
+ }
+ }
+ const wrap = state.editing?.el;
+ if (wrap) {
+ const para = state.editing.para;
+ liveBlockMove = drag.blockId
+ ? { blockId: drag.blockId, dx: drag.lastDx, dy: drag.lastDy }
+ : null;
+ const dT = pageToText(para, drag.lastDx, drag.lastDy);
+ positionWrap(wrap, {
+ ...para,
+ box: { ...drag.b0, x: drag.b0.x + dT.x, top: drag.b0.top + dT.y },
+ });
+ drawOverlay();
+ }
+ } else if (drag.mode === 'ewrap') {
+ const para = state.editing?.para;
+ const dT = pageToText(para, px - drag.startPx, py - drag.startPy);
+ const dx = dT.x;
+ let nx = drag.b0.x,
+ nw = drag.b0.w;
+ if (drag.edge === 'e') nw = Math.max(30, drag.b0.w + dx);
+ else {
+ nw = Math.max(30, drag.b0.w - dx);
+ nx = drag.b0.x + (drag.b0.w - nw);
+ }
+ const wrap = state.editing?.el;
+ if (wrap) {
+ if ((para && !para.rotation) || state.editing?.newGeom)
+ wrap.style.left = nx * state.zoom + 'px';
+ wrap.style.minWidth = nw * state.zoom + 'px';
+ wrap.style.width = nw * state.zoom + 'px';
+ drag.nw = nw;
+ drag.nx = nx;
+ drawOverlay();
+ }
+ } else if (drag.mode === 'mmove') {
+ drag.lastDx = px - drag.startPx;
+ drag.lastDy = py - drag.startPy;
+ {
+ if (!drag.smartT) {
+ const skipPara = new Set(),
+ skipObj = new Set();
+ let u = null;
+ for (const it of state.selection.items) {
+ let r = null;
+ if (it.t === 'para') {
+ skipPara.add(it.para.id);
+ if (!it.para.rotation) {
+ const b = it.para.box;
+ const pc = textToPage(it.para, b.x, b.top);
+ r = { x: pc.x, y: pc.y - b.h, w: b.w, h: b.h };
+ }
+ } else {
+ skipObj.add(it.handle);
+ r = it.bounds;
+ }
+ if (r)
+ u = !u
+ ? { ...r }
+ : (() => {
+ const x0 = Math.min(u.x, r.x),
+ y0 = Math.min(u.y, r.y);
+ const x1 = Math.max(u.x + u.w, r.x + r.w),
+ y1 = Math.max(u.y + u.h, r.y + r.h);
+ return { x: x0, y: y0, w: x1 - x0, h: y1 - y0 };
+ })();
+ }
+ drag.smartT = collectSmartTargets({ skipPara, skipObj });
+ drag.smartEnv = u;
+ }
+ if (drag.smartEnv) {
+ const s2 = smartSnap(
+ drag.smartEnv,
+ drag.lastDx,
+ drag.lastDy,
+ drag.smartT
+ );
+ drag.lastDx = s2.dx;
+ drag.lastDy = s2.dy;
+ showSmartGuides(s2.gv, s2.gh);
+ }
+ }
+ ov.querySelectorAll('.multi-box').forEach((b, i) => {
+ const it = state.selection.items[i];
+ if (!it) return;
+ if (it.t === 'para') {
+ const dT = pageToText(it.para, drag.lastDx, drag.lastDy);
+ const pl = paraPlacement({
+ ...it.para,
+ box: {
+ ...it.para.box,
+ x: it.para.box.x + dT.x,
+ top: it.para.box.top + dT.y,
+ },
+ });
+ b.style.left = pl.x + 'px';
+ b.style.top = pl.y + 'px';
+ } else {
+ const r = objRectView({
+ ...it.bounds,
+ x: it.bounds.x + drag.lastDx,
+ y: it.bounds.y + drag.lastDy,
+ });
+ b.style.left = r.x + 'px';
+ b.style.top = r.y + 'px';
+ }
+ });
+ } else if (drag.mode === 'orot') {
+ drag.moved = true;
+ drag.delta = Math.atan2(py - drag.cy, px - drag.cx) - drag.a0;
+ const box = ov.querySelector('.obj-box');
+ if (box) {
+ box.style.transformOrigin = '50% 50%';
+ box.style.transform = `rotate(${(-drag.delta * 180) / Math.PI}deg)`;
+ }
+ } else if (drag.mode === 'prot') {
+ drag.moved = true;
+ drag.delta = Math.atan2(py - drag.cy, px - drag.cx) - drag.a0;
+ const ch = ov.querySelector('.sel-chrome');
+ if (ch) {
+ ch.style.transformOrigin = '50% 50%';
+ const base = -(drag.para.rotation || 0);
+ ch.style.transform = `rotate(${base - (drag.delta * 180) / Math.PI}deg)`;
+ }
+ } else if (drag.mode === 'newbox' || drag.mode === 'marquee') {
+ drag.moved = true;
+ drag.lastPx = px;
+ drag.lastPy = py;
+ if (!drag.el) {
+ drag.el = document.createElement('div');
+ drag.el.className = 'marquee';
+ ov.appendChild(drag.el);
+ }
+ const z = state.zoom,
+ H = P().pageHeight;
+ const x0 = Math.min(drag.startPx, px) * z,
+ x1 = Math.max(drag.startPx, px) * z;
+ const y0 = (H - Math.max(drag.startPy, py)) * z,
+ y1 = (H - Math.min(drag.startPy, py)) * z;
+ Object.assign(drag.el.style, {
+ left: x0 + 'px',
+ top: y0 + 'px',
+ width: x1 - x0 + 'px',
+ height: y1 - y0 + 'px',
+ });
+ } else if (drag.mode === 'textsel') {
+ drag.moved = true;
+ drag.lastPx = px;
+ drag.lastPy = py;
+ showTextSelection(
+ P().selectText(drag.startPx, drag.startPy, px, py, e.shiftKey ? 1 : 0)
+ );
+ } else if (drag.mode === 'resize') {
+ const nb = resizedBounds(
+ drag.b0,
+ drag.corner,
+ px - drag.startPx,
+ py - drag.startPy
+ );
+ const r = objRectView(nb);
+ const box = ov.querySelector('.obj-box');
+ if (box) {
+ box.style.left = r.x + 'px';
+ box.style.top = r.y + 'px';
+ box.style.width = r.w + 'px';
+ box.style.height = r.h + 'px';
+ }
+ }
+ });
+
+ window.addEventListener('mouseup', (e) => {
+ clearSmartGuides();
+ if (drag && drag.moved) {
+ const { px, py } = toPage(e);
+ if (drag.mode === 'move' && state.selection?.kind === 'object') {
+ snapshotEdit('move object');
+ noteMatrix(drag.handle);
+ P().translateObject(drag.handle, drag.lastDx, drag.lastDy);
+ state.selection.bounds = P().objectBounds(drag.handle);
+ state.dirty = true;
+ refreshAfterMutation();
+ } else if (drag.mode === 'resize' && state.selection?.kind === 'object') {
+ snapshotEdit('resize object');
+ noteMatrix(drag.handle);
+ const nb = resizedBounds(
+ drag.b0,
+ drag.corner,
+ px - drag.startPx,
+ py - drag.startPy
+ );
+ const sx = nb.w / Math.max(0.01, drag.b0.w),
+ sy = nb.h / Math.max(0.01, drag.b0.h);
+ const ax = drag.corner.includes('w')
+ ? drag.b0.x + drag.b0.w
+ : drag.b0.x;
+ const ay = drag.corner.includes('s')
+ ? drag.b0.y + drag.b0.h
+ : drag.b0.y;
+ P().scaleObject(drag.handle, sx, sy, ax, ay);
+ state.selection.bounds = P().objectBounds(drag.handle);
+ state.dirty = true;
+ refreshAfterMutation();
+ } else if (drag.mode === 'pmove') {
+ snapshotEdit('move text');
+ if (P().moveParagraph(drag.para.id, drag.lastDx, drag.lastDy)) {
+ const dT = pageToText(drag.para, drag.lastDx, drag.lastDy);
+ const moved = {
+ ...drag.para,
+ box: {
+ ...drag.para.box,
+ x: drag.para.box.x + dT.x,
+ top: drag.para.box.top + dT.y,
+ },
+ };
+ replaceParagraph(drag.para.id, moved);
+ state.selection = { kind: 'para', para: moved };
+ state.dirty = true;
+ }
+ refreshAfterMutation();
+ } else if (drag.mode === 'pwrap') {
+ const nw = drag.nw ?? drag.b0.w;
+ snapshotEdit('move text');
+ if (drag.edge === 'w' && drag.nx != null) {
+ const d = textToPage(drag.para, drag.nx - drag.b0.x, 0);
+ P().moveParagraph(drag.para.id, d.x, d.y);
+ }
+ const updated = P().resizeParagraph(drag.para.id, nw);
+ if (updated) {
+ replaceParagraph(drag.para.id, updated);
+ state.selection = { kind: 'para', para: updated };
+ state.dirty = true;
+ }
+ refreshAfterMutation();
+ } else if (drag.mode === 'mmove' && state.selection?.kind === 'multi') {
+ snapshotEdit('move');
+ for (const it of state.selection.items) noteItemMatrix(it);
+ for (const it of state.selection.items)
+ moveItem(it, drag.lastDx, drag.lastDy);
+ state.dirty = true;
+ refreshAfterMutation();
+ } else if (drag.mode === 'orot') {
+ const deg = (drag.delta * 180) / Math.PI;
+ if (Math.abs(deg) > 0.5) {
+ snapshotEdit('rotate object');
+ noteMatrix(drag.handle);
+ P().rotateObjectsAbout([drag.handle], deg, drag.cx, drag.cy);
+ if (state.selection?.kind === 'object')
+ state.selection.bounds = P().objectBounds(drag.handle);
+ state.dirty = true;
+ }
+ refreshAfterMutation();
+ } else if (drag.mode === 'prot' && drag.para) {
+ const deg = (drag.delta * 180) / Math.PI;
+ if (Math.abs(deg) > 0.5) {
+ snapshotEdit('rotate text');
+ const handles = P().paragraphObjects(drag.para.id);
+ for (const h of handles) noteMatrix(h);
+ P().rotateObjectsAbout(handles, deg, drag.cx, drag.cy);
+ state.dirty = true;
+ refreshModel();
+ state.selection = null;
+ renderPage();
+ updateChrome();
+ } else drawOverlay();
+ } else if (drag.mode === 'textsel') {
+ if (state.textSel?.text) {
+ const n = state.textSel.blocks.length;
+ toast(
+ `Selected ${state.textSel.text.length} characters across ` +
+ `${n} block${n === 1 ? '' : 's'} — ⌘C to copy.`
+ );
+ }
+ } else if (drag.mode === 'newbox') {
+ drag.el?.remove();
+ const x0 = Math.min(drag.startPx, drag.lastPx ?? drag.startPx);
+ const x1 = Math.max(drag.startPx, drag.lastPx ?? drag.startPx);
+ const yTop = Math.max(drag.startPy, drag.lastPy ?? drag.startPy);
+ const w = x1 - x0;
+ const d0 = drag;
+ drag = null;
+ beginNewTextBox(x0, yTop, 14, { x: d0.clientX, y: d0.clientY }, w);
+ return;
+ } else if (drag.mode === 'marquee') {
+ drag.el?.remove();
+ const x0 = Math.min(drag.startPx, drag.lastPx ?? drag.startPx);
+ const x1 = Math.max(drag.startPx, drag.lastPx ?? drag.startPx);
+ const y0 = Math.min(drag.startPy, drag.lastPy ?? drag.startPy);
+ const y1 = Math.max(drag.startPy, drag.lastPy ?? drag.startPy);
+ const items = [];
+ for (const p of state.paragraphs) {
+ if (!p.editable || !scopeAllowsPara()) continue;
+ const e2 = paraEnvelope(p);
+ if (e2.x < x1 && e2.x + e2.w > x0 && e2.y < y1 && e2.y + e2.h > y0)
+ items.push({ t: 'para', para: p });
+ }
+ for (let i = 0; i < P().objectCount(); i++) {
+ const o = P().objectAt(i);
+ if (!o || !scopeAllowsObj(o.type)) continue;
+ const b = P().objectBounds(o.handle);
+ if (b && b.x < x1 && b.x + b.w > x0 && b.y < y1 && b.y + b.h > y0)
+ items.push({ t: 'obj', handle: o.handle, type: o.type, bounds: b });
+ }
+ setMultiSelection(items);
+ } else if (drag.mode === 'ewrap' && state.editing?.newGeom) {
+ const g = state.editing.newGeom;
+ g.width = drag.nw ?? drag.b0.w;
+ if (drag.edge === 'w' && drag.nx != null) g.x = drag.nx;
+ drawOverlay();
+ } else if (drag.mode === 'ewrap' && state.editing?.para) {
+ const e2 = state.editing;
+ const runs = parseEditor(e2.editable, e2.para.runs);
+ snapshotEdit('edit text');
+ let id = e2.para.id;
+ const committed = P().commitParagraph(
+ id,
+ runs.length
+ ? runs
+ : e2.para.runs.map((r, i) => ({ ...r, sourceIndex: i })),
+ e2.para.format
+ );
+ if (committed) replaceParagraph(id, committed);
+ const nw = drag.nw ?? drag.b0.w;
+ if (drag.edge === 'w' && drag.nx != null) {
+ const d = textToPage(e2.para, drag.nx - drag.b0.x, 0);
+ P().moveParagraph(id, d.x, d.y);
+ }
+ const resized = P().resizeParagraph(id, nw);
+ state.editing = null;
+ state.dirty = true;
+ if (resized) {
+ replaceParagraph(id, resized);
+ renderPage();
+ beginEdit(resized);
+ } else refreshAfterMutation();
+ } else if (drag.mode === 'emove' && state.editing?.para) {
+ snapshotEdit('move text');
+ const es = state.editing;
+ liveBlockMove = null;
+ const movers = drag.blockId
+ ? state.paragraphs.filter((q) => q.blockId === drag.blockId)
+ : [es.para];
+ for (const q of movers) {
+ if (!P().moveParagraph(q.id, drag.lastDx, drag.lastDy)) continue;
+ const dT = pageToText(q, drag.lastDx, drag.lastDy);
+ const moved = {
+ ...q,
+ box: { ...q.box, x: q.box.x + dT.x, top: q.box.top + dT.y },
+ };
+ replaceParagraph(q.id, moved);
+ if (q.id === es.para.id) {
+ es.para = moved;
+ state.selection = { kind: 'para', para: moved };
+ }
+ state.dirty = true;
+ }
+ positionWrap(es.el, es.para);
+ renderPage();
+ updateChrome();
+ }
+ } else if (
+ drag &&
+ !drag.moved &&
+ drag.mode === 'pmove' &&
+ drag.clientX != null
+ ) {
+ beginEdit(drag.para, { x: drag.clientX, y: drag.clientY });
+ } else if (
+ drag &&
+ !drag.moved &&
+ drag.mode === 'move' &&
+ drag.clientX != null
+ ) {
+ const { px, py } = toPage(e);
+ const paraOver = hitTestParagraphStrong(px, py);
+ if (paraOver) {
+ state.selection = null;
+ beginEdit(paraOver, { x: drag.clientX, y: drag.clientY });
+ }
+ } else if (drag && !drag.moved && drag.mode === 'newbox') {
+ drag.el?.remove();
+ const d0 = drag;
+ drag = null;
+ beginNewTextBox(d0.startPx, d0.startPy, 14, {
+ x: d0.clientX,
+ y: d0.clientY,
+ });
+ return;
+ } else if (drag && !drag.moved && drag.mode === 'marquee') {
+ drag.el?.remove();
+ selectObject(null);
+ } else if (drag && !drag.moved && drag.mode === 'prot') {
+ drawOverlay();
+ }
+ liveBlockMove = null;
+ drag = null;
+ });
+}
+
+function resizedBounds(b, corner, dx, dy) {
+ let { x, y, w, h } = b;
+ if (corner.includes('e')) w = Math.max(4, b.w + dx);
+ if (corner.includes('w')) {
+ w = Math.max(4, b.w - dx);
+ x = b.x + (b.w - w);
+ }
+ if (corner.includes('n')) h = Math.max(4, b.h + dy);
+ if (corner.includes('s')) {
+ h = Math.max(4, b.h - dy);
+ y = b.y + (b.h - h);
+ }
+ return { x, y, w, h };
+}
+
+const escapeRegex = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
+const foldAccents = (str) =>
+ Array.from(str, (ch) => [...ch.normalize('NFD')][0] || ch).join('');
+function findOptions() {
+ return {
+ cs: $('caseSens').checked,
+ word: $('wholeWord').checked,
+ noAcc: $('noAccents').checked,
+ };
+}
+function findRegex(needle, opts, global) {
+ const n = escapeRegex(opts.noAcc ? foldAccents(needle) : needle);
+ const body = opts.word ? '(? p.runs.map((r) => r.text).join('');
+const findHaystack = (text, opts) => (opts.noAcc ? foldAccents(text) : text);
+
+function paraMatches(p, needle, opts) {
+ const hay = findHaystack(paraText(p), opts);
+ const out = [];
+ for (const m of hay.matchAll(findRegex(needle, opts, true))) {
+ if (!m[0].length) continue;
+ out.push({ start: m.index, end: m.index + m[0].length });
+ }
+ return out;
+}
+
+function runsWithReplacements(para, spans, replacement) {
+ const runs = para.runs.map((r, i) => ({
+ ...r,
+ rgba: r.rgba >>> 0,
+ sourceIndex: i,
+ }));
+ const locate = (off) => {
+ let base = 0;
+ for (let i = 0; i < runs.length; i++) {
+ const len = runs[i].text.length;
+ if (off <= base + len) return { i, o: off - base };
+ base += len;
+ }
+ const last = runs.length - 1;
+ return { i: last, o: runs[last].text.length };
+ };
+ for (let k = spans.length - 1; k >= 0; k--) {
+ const s0 = locate(spans[k].start),
+ e0 = locate(spans[k].end);
+ if (s0.i === e0.i) {
+ runs[s0.i].text =
+ runs[s0.i].text.slice(0, s0.o) +
+ replacement +
+ runs[s0.i].text.slice(e0.o);
+ } else {
+ runs[s0.i].text = runs[s0.i].text.slice(0, s0.o) + replacement;
+ for (let i = s0.i + 1; i < e0.i; i++) runs[i].text = '';
+ runs[e0.i].text = runs[e0.i].text.slice(e0.o);
+ }
+ }
+ return runs;
+}
+
+function matchQuads(para, start, end) {
+ let pv = null;
+ try {
+ pv = P().previewParagraph(para.id, runsToInput(para.runs), para.format);
+ } catch {
+ pv = null;
+ }
+ if (!pv || !pv.lines || !pv.lines.length) return [];
+ const out = [];
+ for (let k = 0; k < pv.lines.length; k++) {
+ const L = pv.lines[k];
+ if (!L.cx || !L.cx.length || L.flat < 0) continue;
+ let nextFlat = Infinity;
+ for (let j = k + 1; j < pv.lines.length; j++)
+ if (pv.lines[j].flat >= 0) {
+ nextFlat = pv.lines[j].flat;
+ break;
+ }
+ const a = Math.max(start, L.flat),
+ b = Math.min(end, nextFlat);
+ if (a >= b) continue;
+ const at = (i) => L.x + L.cx[Math.max(0, Math.min(i, L.cx.length - 1))];
+ const x0 = at(a - L.flat),
+ x1 = at(b - L.flat);
+ const h = (L.size || 12) * 1.25;
+ out.push({
+ x: Math.min(x0, x1),
+ y: L.baseline - h * 0.25,
+ w: Math.max(1, Math.abs(x1 - x0)),
+ h,
+ });
+ }
+ return out;
+}
+
+function showFindHighlight(para, start, end) {
+ clearTextSelection();
+ const quads = matchQuads(para, start, end);
+ if (!quads.length) return;
+ const ov = $('overlay'),
+ z = state.zoom,
+ H = P().pageHeight;
+ for (const q of quads) {
+ const el = document.createElement('div');
+ el.className = 'textsel';
+ Object.assign(el.style, {
+ left: q.x * z + 'px',
+ top: (H - q.y - q.h) * z + 'px',
+ width: q.w * z + 'px',
+ height: q.h * z + 'px',
+ });
+ ov.appendChild(el);
+ }
+}
+
+function pageMatches(needle, opts) {
+ const out = [];
+ for (const p of state.paragraphs) {
+ if (!p.editable) continue;
+ for (const m of paraMatches(p, needle, opts))
+ out.push({ paraId: p.id, start: m.start, end: m.end });
+ }
+ return out;
+}
+
+function findStep(needle, dir) {
+ scanningPages = true;
+ try {
+ return findStepScan(needle, dir);
+ } finally {
+ scanningPages = false;
+ refreshPageExtras();
+ }
+}
+
+function findStepScan(needle, dir) {
+ const eng = P();
+ const opts = findOptions();
+ const key = needle + ' ' + JSON.stringify(opts);
+ const pages = Math.max(1, eng.pageCount);
+ if (!state.find || state.find.key !== key)
+ state.find = { key, page: eng.pageIndex, i: dir > 0 ? -1 : 0, total: 0 };
+ for (let step = 0; step <= pages; step++) {
+ const idx = (((state.find.page + dir * step) % pages) + pages) % pages;
+ if (idx !== eng.pageIndex) {
+ eng.loadPage(idx);
+ refreshModel();
+ }
+ const all = pageMatches(needle, opts);
+ const i = step === 0 ? state.find.i + dir : dir > 0 ? 0 : all.length - 1;
+ if (all.length && i >= 0 && i < all.length) {
+ const hit = all[i];
+ const para = state.paragraphs.find((q) => q.id === hit.paraId);
+ state.find = { key, page: idx, i, total: all.length };
+ if (para) {
+ renderPage();
+ state.selection = { kind: 'para', para };
+ drawOverlay();
+ showFindHighlight(para, hit.start, hit.end);
+ updateChrome();
+ }
+ return {
+ paraId: hit.paraId,
+ start: hit.start,
+ end: hit.end,
+ i,
+ total: all.length,
+ page: idx,
+ text: para ? paraText(para).slice(hit.start, hit.end) : '',
+ };
+ }
+ state.find = {
+ key,
+ page: idx,
+ i: dir > 0 ? -1 : all.length,
+ total: all.length,
+ };
+ }
+ return null;
+}
+const findNext = (needle) => findStep(needle, 1);
+
+function replaceAll(needle, replacement) {
+ scanningPages = true;
+ try {
+ return replaceAllScan(needle, replacement);
+ } finally {
+ scanningPages = false;
+ refreshPageExtras();
+ }
+}
+
+function replaceAllScan(needle, replacement) {
+ const eng = P();
+ const opts = findOptions();
+ let total = 0;
+ snapshot();
+ for (let idx = 0; idx < Math.max(1, eng.pageCount); idx++) {
+ if (idx !== eng.pageIndex) {
+ eng.loadPage(idx);
+ refreshModel();
+ }
+ for (const para of [...state.paragraphs]) {
+ if (!para.editable) continue;
+ const spans = paraMatches(para, needle, opts);
+ if (!spans.length) continue;
+ const runs = runsWithReplacements(para, spans, replacement);
+ const u = P().commitParagraph(para.id, runs, para.format);
+ if (u) {
+ replaceParagraph(para.id, u);
+ total += spans.length;
+ }
+ }
+ }
+ state.find = null;
+ state.dirty = total > 0;
+ renderPage();
+ updateChrome();
+ return total;
+}
+
+function replaceCurrent(needle, replacement) {
+ if (!state.find || state.find.i < 0) return 0;
+ const opts = findOptions();
+ const all = pageMatches(needle, opts);
+ const hit = all[state.find.i];
+ if (!hit) return 0;
+ const para = state.paragraphs.find((q) => q.id === hit.paraId);
+ if (!para) return 0;
+ snapshotEdit('edit text', para);
+ const runs = runsWithReplacements(para, [hit], replacement);
+ const u = P().commitParagraph(para.id, runs, para.format);
+ if (!u) return 0;
+ replaceParagraph(para.id, u);
+ state.find.i -= 1;
+ state.dirty = true;
+ refreshAfterMutation();
+ return 1;
+}
+
+function adjustListLevel(delta) {
+ let para = state.editing?.para || state.selection?.para;
+ if (!para) return;
+ if (state.editing) endEdit(true);
+ para = state.paragraphs.find((q) => q.id === para.id) || para;
+ const cur = para.format.listLevel || 0;
+ const next = Math.max(0, Math.min(8, cur + delta));
+ if (next === cur) return;
+ snapshotEdit('move text');
+ const STEP = 18;
+ if (!P().moveParagraph(para.id, (next - cur) * STEP, 0)) {
+ if (state.undo.length) state.undo.pop();
+ return;
+ }
+ para.box.x += (next - cur) * STEP;
+ const runs = para.runs.map((r, i) => ({
+ ...r,
+ rgba: r.rgba >>> 0,
+ sourceIndex: i,
+ }));
+ const u = P().commitParagraph(para.id, runs, {
+ ...para.format,
+ listLevel: next,
+ });
+ if (u) replaceParagraph(para.id, u);
+ state.dirty = true;
+ refreshAfterMutation();
+}
+
+function applyPathStroke() {
+ if (state.selection?.kind !== 'object' || state.selection.type !== OBJ.PATH)
+ return;
+ const rgba =
+ ((parseInt($('pStrokeColor').value.slice(1), 16) << 8) | 0xff) >>> 0;
+ const w = parseFloat($('pStrokeW').value) || 0;
+ snapshot();
+ P().setPathStroke(state.selection.handle, rgba, Math.max(0.1, w));
+ scheduleRegen();
+ state.dirty = true;
+ renderPage();
+ drawOverlay();
+ updateChrome();
+}
+
+const ECPARA_MIME = 'application/x-ecpara+json';
+
+const esc = (s) =>
+ s.replace(
+ /[&<>"]/g,
+ (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"' })[c]
+ );
+
+function runsToHtml(runs) {
+ const spans = runs.map((r) => {
+ const deco = [
+ r.underline ? 'underline' : '',
+ r.strike ? 'line-through' : '',
+ ]
+ .filter(Boolean)
+ .join(' ');
+ const css = [
+ `font-family:'${(r.family || 'sans-serif').replace(/'/g, '')}'`,
+ `font-size:${(r.size || 12).toFixed(1)}pt`,
+ r.bold ? 'font-weight:700' : '',
+ r.italic ? 'font-style:italic' : '',
+ `color:${rgbaToCss(r.rgba >>> 0 || 255)}`,
+ deco ? `text-decoration:${deco}` : '',
+ ]
+ .filter(Boolean)
+ .join(';');
+ return `${esc(r.text).replace(/\n/g, '
')}`;
+ });
+ return `${spans.join('')}
`;
+}
+
+function cssColorToRgba(css) {
+ if (!css) return 255;
+ const probe = document.createElement('span');
+ probe.style.color = css;
+ document.body.appendChild(probe);
+ const m = getComputedStyle(probe).color.match(/rgba?\(([^)]+)\)/);
+ probe.remove();
+ if (!m) return 255;
+ const p = m[1].split(',').map((x) => parseFloat(x.trim()));
+ const a = p.length > 3 ? Math.round(p[3] * 255) : 255;
+ return (
+ (((p[0] & 255) << 24) | ((p[1] & 255) << 16) | ((p[2] & 255) << 8) | a) >>>
+ 0
+ );
+}
+
+function htmlToRuns(html) {
+ const doc = new DOMParser().parseFromString(html, 'text/html');
+ const runs = [];
+ const push = (text, st) => {
+ if (!text) return;
+ runs.push({
+ text,
+ family: st.family,
+ size: st.size,
+ rgba: st.rgba,
+ bold: st.bold,
+ italic: st.italic,
+ underline: st.underline,
+ strike: st.strike,
+ script: 0,
+ renderMode: 0,
+ strokeRgba: 0,
+ strokeWidth: 1,
+ hScale: 1,
+ rise: 0,
+ sourceIndex: -1,
+ });
+ };
+ const parseSize = (v, inherited) => {
+ const n = parseFloat(v);
+ if (!(n > 0)) return inherited;
+ if (/pt$/.test(v)) return n;
+ if (/px$/.test(v)) return n * 0.75;
+ if (/em$/.test(v)) return inherited * n;
+ if (/%$/.test(v)) return (inherited * n) / 100;
+ return n;
+ };
+ const walk = (node, st) => {
+ for (const child of node.childNodes) {
+ if (child.nodeType === 3) {
+ push(child.nodeValue.replace(/\s+/g, ' '), st);
+ continue;
+ }
+ if (child.nodeType !== 1) continue;
+ const tag = child.tagName.toLowerCase();
+ if (tag === 'script' || tag === 'style' || tag === 'head') continue;
+ if (tag === 'br') {
+ if (runs.length) runs[runs.length - 1].text += '\n';
+ continue;
+ }
+ const s = { ...st };
+ const cst = child.style || {};
+ if (cst.fontFamily)
+ s.family =
+ cst.fontFamily.split(',')[0].replace(/['"]/g, '').trim() || s.family;
+ if (cst.fontSize) s.size = parseSize(cst.fontSize, st.size);
+ const w = cst.fontWeight;
+ if (tag === 'b' || tag === 'strong') s.bold = true;
+ else if (w) s.bold = w === 'bold' || parseInt(w, 10) >= 600;
+ if (tag === 'i' || tag === 'em') s.italic = true;
+ else if (cst.fontStyle)
+ s.italic = cst.fontStyle === 'italic' || cst.fontStyle === 'oblique';
+ const deco = cst.textDecorationLine || cst.textDecoration || '';
+ if (tag === 'u') s.underline = true;
+ else if (deco) s.underline = deco.includes('underline');
+ if (tag === 's' || tag === 'strike' || tag === 'del') s.strike = true;
+ else if (deco) s.strike = deco.includes('line-through');
+ if (cst.color) s.rgba = cssColorToRgba(cst.color);
+ if (
+ (tag === 'p' || tag === 'div' || tag === 'li') &&
+ runs.length &&
+ !runs[runs.length - 1].text.endsWith('\n')
+ )
+ runs[runs.length - 1].text += '\n';
+ walk(child, s);
+ }
+ };
+ walk(doc.body, {
+ family: 'Helvetica',
+ size: 12,
+ rgba: 255,
+ bold: false,
+ italic: false,
+ underline: false,
+ strike: false,
+ });
+ const out = [];
+ for (const r of runs) {
+ if (!r.text) continue;
+ const last = out[out.length - 1];
+ if (
+ last &&
+ last.family === r.family &&
+ last.size === r.size &&
+ last.rgba === r.rgba &&
+ last.bold === r.bold &&
+ last.italic === r.italic &&
+ last.underline === r.underline &&
+ last.strike === r.strike
+ )
+ last.text += r.text;
+ else out.push(r);
+ }
+ return out.filter((r) => r.text.trim() || r.text.includes('\n'));
+}
+
+const CLIP_FONT_BUDGET = 4 << 20;
+function collectBlockFonts(p) {
+ const out = {};
+ let spent = 0;
+ for (let i = 0; i < p.runs.length; i++) {
+ const r = p.runs[i];
+ const key = (r.family || '') + '|' + (r.bold ? 1 : 0) + (r.italic ? 1 : 0);
+ if (!r.family || out[key]) continue;
+ let bytes = null;
+ try {
+ bytes = P().runFontData(p.id, i);
+ } catch {
+ bytes = null;
+ }
+ if (!bytes || !bytes.length) continue;
+ if (spent + bytes.length > CLIP_FONT_BUDGET) continue;
+ spent += bytes.length;
+ let bin = '';
+ for (let k = 0; k < bytes.length; k += 0x8000)
+ bin += String.fromCharCode.apply(null, bytes.subarray(k, k + 0x8000));
+ out[key] = btoa(bin);
+ }
+ return out;
+}
+
+function adoptBlockFonts(fonts) {
+ if (!fonts) return;
+ for (const [key, b64] of Object.entries(fonts)) {
+ if (PdfEngine.localFonts.has(key)) continue;
+ try {
+ const bin = atob(b64);
+ const bytes = new Uint8Array(bin.length);
+ for (let i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i);
+ PdfEngine.localFonts.set(key, bytes);
+ const fam = key.slice(0, key.lastIndexOf('|'));
+ if (fam && !PdfEngine.localFonts.has(fam))
+ PdfEngine.localFonts.set(fam, bytes);
+ } catch {}
+ }
+}
+
+const spell = {
+ list: [],
+ at: -1,
+ ignored: new Set(),
+ loading: null,
+ bytes: null,
+};
+
+async function spellDictionary() {
+ if (P()._dictLoaded) return true;
+ if (!spell.loading) {
+ spell.loading = (async () => {
+ const res = await fetch('dict/en.txt.gz');
+ if (!res.ok) throw new Error('dictionary not found');
+ let bytes;
+ if (typeof DecompressionStream === 'function') {
+ const ds = new DecompressionStream('gzip');
+ bytes = new Uint8Array(
+ await new Response(res.body.pipeThrough(ds)).arrayBuffer()
+ );
+ } else {
+ bytes = new Uint8Array(await res.arrayBuffer());
+ }
+ spell.bytes = bytes;
+ return spellInstall();
+ })().catch((e) => {
+ toast('Spelling: ' + e.message);
+ return false;
+ });
+ }
+ return spell.loading;
+}
+function spellInstall() {
+ if (!spell.bytes) return false;
+ const n = P().spellLoad(spell.bytes);
+ const extra = userDictionary();
+ if (extra.length)
+ P().spellLoad(new TextEncoder().encode(extra.join('\n') + '\n'));
+ return n > 0;
+}
+
+const USER_DICT_KEY = 'ec.userDict';
+function userDictionary() {
+ try {
+ return JSON.parse(localStorage.getItem(USER_DICT_KEY) || '[]');
+ } catch {
+ return [];
+ }
+}
+function userDictionaryAdd(w) {
+ const all = new Set(userDictionary());
+ all.add(w.toLowerCase());
+ try {
+ localStorage.setItem(USER_DICT_KEY, JSON.stringify([...all]));
+ } catch {}
+}
+
+async function spellStart() {
+ if (!P()?.doc) return;
+ $('spellbar').hidden = false;
+ $('spellStatus').textContent = 'Loading dictionary…';
+ const ok = await spellDictionary();
+ if (!ok) {
+ $('spellStatus').textContent = 'No dictionary.';
+ return;
+ }
+ spellRescan();
+ spellNext();
+}
+function spellRescan() {
+ let r = P().spellCheckPage();
+ if (r && r.ready === false && spellInstall()) r = P().spellCheckPage();
+ spell.list = (r?.words || []).filter(
+ (w) =>
+ !spell.ignored.has(w.word.toLowerCase()) &&
+ !userDictionary().includes(w.word.toLowerCase())
+ );
+ spell.at = -1;
+}
+function spellHighlight(hit) {
+ document.querySelectorAll('.spellhit').forEach((el) => el.remove());
+ if (!hit) return;
+ const para = state.paragraphs.find((p) => p.id === hit.id);
+ if (!para) return;
+ const line =
+ (para.lines || [])
+ .slice()
+ .reverse()
+ .find((l) => (l.off ?? 0) <= hit.from) || para.lines?.[0];
+ if (!line) return;
+ const z = state.zoom,
+ H = P().pageHeight;
+ const el = document.createElement('div');
+ el.className = 'spellhit';
+ const size = Math.max(...para.runs.map((r) => r.size || 12));
+ Object.assign(el.style, {
+ left: (line.hasPenX === false ? line.x : (line.px ?? line.x)) * z + 'px',
+ top: (H - line.y - 0.85 * size) * z + 'px',
+ width: line.w * z + 'px',
+ height: 1.1 * size * z + 'px',
+ });
+ $('overlay').appendChild(el);
+ el.scrollIntoView({ block: 'center', behavior: 'smooth' });
+}
+function spellShow() {
+ const hit = spell.list[spell.at];
+ $('spellWord').textContent = hit ? hit.word : '—';
+ $('spellFix').value = hit ? hit.word : '';
+ $('spellStatus').textContent = spell.list.length
+ ? `${spell.at + 1} of ${spell.list.length}`
+ : 'No misspellings found.';
+ spellHighlight(hit);
+}
+function spellNext() {
+ if (!spell.list.length) {
+ spellShow();
+ return;
+ }
+ spell.at = (spell.at + 1) % spell.list.length;
+ spellShow();
+}
+function spellChange() {
+ const hit = spell.list[spell.at];
+ const fix = $('spellFix').value;
+ if (!hit || !fix || fix === hit.word) return;
+ const para = state.paragraphs.find((p) => p.id === hit.id);
+ if (!para) return;
+ const runs = [];
+ let at = 0;
+ for (let i = 0; i < para.runs.length; i++) {
+ const r = para.runs[i];
+ const start = at,
+ end = at + r.text.length;
+ let text = r.text;
+ if (hit.from < end && hit.to > start) {
+ const a = Math.max(0, hit.from - start),
+ b = Math.min(r.text.length, hit.to - start);
+ text =
+ r.text.slice(0, a) + (start <= hit.from ? fix : '') + r.text.slice(b);
+ }
+ runs.push({ ...r, text, rgba: r.rgba >>> 0, sourceIndex: i });
+ at = end;
+ }
+ snapshotEdit('spelling', para);
+ const u = P().commitParagraph(
+ para.id,
+ runs.filter((r) => r.text.length),
+ para.format
+ );
+ if (u) replaceParagraph(para.id, u);
+ refreshAfterMutation();
+ spellRescan();
+ spellNext();
+}
+
+function showTextSelection(sel) {
+ clearTextSelection(false);
+ state.textSel = sel;
+ if (!sel || !sel.quads.length) return;
+ const ov = $('overlay'),
+ z = state.zoom,
+ H = P().pageHeight;
+ for (const q of sel.quads) {
+ const el = document.createElement('div');
+ el.className = 'textsel';
+ Object.assign(el.style, {
+ left: q.x * z + 'px',
+ top: (H - q.y - q.h) * z + 'px',
+ width: q.w * z + 'px',
+ height: q.h * z + 'px',
+ });
+ ov.appendChild(el);
+ }
+}
+function clearTextSelection(drop = true) {
+ document.querySelectorAll('.textsel').forEach((el) => el.remove());
+ if (drop) state.textSel = null;
+}
+
+function wireBlockClipboard() {
+ document.addEventListener('copy', (e) => {
+ if (!state.editing && state.textSel?.text) {
+ e.clipboardData.setData('text/plain', state.textSel.text);
+ e.preventDefault();
+ toast('Selected text copied.');
+ return;
+ }
+ if (state.editing || state.selection?.kind !== 'para') return;
+ const sel = window.getSelection();
+ if (sel && String(sel).length) return;
+ const p = state.selection.para;
+ const payload = {
+ v: 1,
+ runs: p.runs.map((r) => ({ ...r })),
+ format: { ...p.format },
+ w: p.box.w,
+ fonts: collectBlockFonts(p),
+ };
+ e.clipboardData.setData(ECPARA_MIME, JSON.stringify(payload));
+ e.clipboardData.setData('text/html', runsToHtml(p.runs));
+ e.clipboardData.setData('text/plain', p.runs.map((r) => r.text).join(''));
+ e.preventDefault();
+ toast('Block copied — paste to duplicate with styles.');
+ });
+ document.addEventListener('paste', (e) => {
+ if (state.editing || !P()?.doc) return;
+ const raw = e.clipboardData.getData(ECPARA_MIME);
+ let data = null;
+ if (raw) {
+ try {
+ data = JSON.parse(raw);
+ } catch {
+ data = null;
+ }
+ if (!data || data.v !== 1 || !Array.isArray(data.runs)) return;
+ } else {
+ const html = e.clipboardData.getData('text/html');
+ const runs = html ? htmlToRuns(html) : null;
+ if (!runs || !runs.length) return;
+ data = { runs, format: {}, w: 300 };
+ }
+ e.preventDefault();
+ adoptBlockFonts(data.fonts);
+ const H = P().pageHeight;
+ const x = 60,
+ yTop = H - 60;
+ snapshotEdit('add text');
+ const runs = data.runs.map((r) => ({
+ ...r,
+ rgba: r.rgba >>> 0 || 255,
+ sourceIndex: -1,
+ }));
+ const created = P().addParagraph(
+ x,
+ yTop,
+ Math.max(40, data.w || 200),
+ runs,
+ data.format || {}
+ );
+ if (created) {
+ state.paragraphs.push(created);
+ state.selection = { kind: 'para', para: created };
+ state.dirty = true;
+ refreshAfterMutation();
+ toast('Block pasted.');
+ } else if (state.undo.length) state.undo.pop();
+ });
+}
+
+const SNAP_TOL = 4;
+function snapDelta(env, dx, dy) {
+ if (!env) return { dx, dy };
+ let bestX = null,
+ bestY = null;
+ for (const gx of state.guides.v) {
+ for (const cand of [
+ env.x + dx,
+ env.x + env.w + dx,
+ env.x + env.w / 2 + dx,
+ ]) {
+ const d = gx - cand;
+ if (
+ Math.abs(d) <= SNAP_TOL &&
+ (bestX == null || Math.abs(d) < Math.abs(bestX))
+ )
+ bestX = d;
+ }
+ }
+ for (const gy of state.guides.h) {
+ for (const cand of [
+ env.y + dy,
+ env.y + env.h + dy,
+ env.y + env.h / 2 + dy,
+ ]) {
+ const d = gy - cand;
+ if (
+ Math.abs(d) <= SNAP_TOL &&
+ (bestY == null || Math.abs(d) < Math.abs(bestY))
+ )
+ bestY = d;
+ }
+ }
+ return { dx: dx + (bestX || 0), dy: dy + (bestY || 0) };
+}
+
+function collectSmartTargets(opts = {}) {
+ const eng = P();
+ const t = [];
+ const W = eng.pageWidth,
+ H = eng.pageHeight;
+ t.push({ x: 0, y: 0, w: W, h: H });
+ for (const q of state.paragraphs) {
+ if (q.rotation || opts.skipPara?.has(q.id)) continue;
+ t.push({ x: q.box.x, y: q.box.top - q.box.h, w: q.box.w, h: q.box.h });
+ }
+ for (const o of objectIndex()) {
+ if (t.length >= 400) break;
+ if (o.type === 1 || opts.skipObj?.has(o.handle)) continue;
+ const b = o.bounds;
+ if (b.w > 2 && b.h > 2) t.push(b);
+ }
+ return t;
+}
+
+function smartSnap(env, dx, dy, targets) {
+ if (!env) return { dx, dy, gv: null, gh: null };
+ const edgesOf = (r) => ({
+ xs: [r.x, r.x + r.w / 2, r.x + r.w],
+ ys: [r.y, r.y + r.h / 2, r.y + r.h],
+ });
+ const me = edgesOf({ x: env.x + dx, y: env.y + dy, w: env.w, h: env.h });
+ let bx = null,
+ by = null;
+ for (const g of state.guides.v)
+ for (const c of me.xs) {
+ const d = g - c;
+ if (Math.abs(d) <= SNAP_TOL && (!bx || Math.abs(d) < Math.abs(bx.d)))
+ bx = { d, coord: g, ruler: true };
+ }
+ for (const g of state.guides.h)
+ for (const c of me.ys) {
+ const d = g - c;
+ if (Math.abs(d) <= SNAP_TOL && (!by || Math.abs(d) < Math.abs(by.d)))
+ by = { d, coord: g, ruler: true };
+ }
+ const better = (cand, best) =>
+ !best ||
+ Math.abs(cand.d) < Math.abs(best.d) - 1e-9 ||
+ (Math.abs(cand.d) <= Math.abs(best.d) + 1e-9 && cand.kind && !best.kind);
+ for (const tr of targets) {
+ const te = edgesOf(tr);
+ for (let gi = 0; gi < 3; gi++)
+ for (let ci = 0; ci < 3; ci++) {
+ const d = te.xs[gi] - me.xs[ci];
+ const cand = { d, coord: te.xs[gi], kind: (gi === 1) === (ci === 1) };
+ if (Math.abs(d) <= SNAP_TOL && better(cand, bx)) bx = cand;
+ }
+ for (let gi = 0; gi < 3; gi++)
+ for (let ci = 0; ci < 3; ci++) {
+ const d = te.ys[gi] - me.ys[ci];
+ const cand = { d, coord: te.ys[gi], kind: (gi === 1) === (ci === 1) };
+ if (Math.abs(d) <= SNAP_TOL && better(cand, by)) by = cand;
+ }
+ }
+ dx += bx?.d || 0;
+ dy += by?.d || 0;
+ const fin = { x: env.x + dx, y: env.y + dy, w: env.w, h: env.h };
+ let gv = null,
+ gh = null;
+ if (bx) {
+ let lo = fin.y,
+ hi = fin.y + fin.h;
+ if (bx.ruler) {
+ lo = 0;
+ hi = P().pageHeight;
+ } else
+ for (const tr of targets) {
+ const xs = [tr.x, tr.x + tr.w / 2, tr.x + tr.w];
+ if (xs.some((v) => Math.abs(v - bx.coord) < 0.01)) {
+ lo = Math.min(lo, tr.y);
+ hi = Math.max(hi, tr.y + tr.h);
+ }
+ }
+ gv = { x: bx.coord, lo, hi };
+ }
+ if (by) {
+ let lo = fin.x,
+ hi = fin.x + fin.w;
+ if (by.ruler) {
+ lo = 0;
+ hi = P().pageWidth;
+ } else
+ for (const tr of targets) {
+ const ys = [tr.y, tr.y + tr.h / 2, tr.y + tr.h];
+ if (ys.some((v) => Math.abs(v - by.coord) < 0.01)) {
+ lo = Math.min(lo, tr.x);
+ hi = Math.max(hi, tr.x + tr.w);
+ }
+ }
+ gh = { y: by.coord, lo, hi };
+ }
+ return { dx, dy, gv, gh };
+}
+
+function showSmartGuides(gv, gh) {
+ const wrap = $('pageWrap');
+ for (const el of wrap.querySelectorAll('.smart-guide')) el.remove();
+ if (!P()?.doc) return;
+ const H = P().pageHeight,
+ z = state.zoom;
+ const mk = (css) => {
+ const g = document.createElement('div');
+ g.className = 'smart-guide';
+ g.style.cssText =
+ 'position:absolute;z-index:6;background:#ff2d55;pointer-events:none;' +
+ css;
+ wrap.appendChild(g);
+ };
+ const PAD = 6;
+ if (gv)
+ mk(
+ `left:${(gv.x * z).toFixed(1)}px;top:${((H - gv.hi) * z - PAD).toFixed(1)}px;` +
+ `width:1px;height:${((gv.hi - gv.lo) * z + PAD * 2).toFixed(1)}px;`
+ );
+ if (gh)
+ mk(
+ `top:${((H - gh.y) * z).toFixed(1)}px;left:${(gh.lo * z - PAD).toFixed(1)}px;` +
+ `height:1px;width:${((gh.hi - gh.lo) * z + PAD * 2).toFixed(1)}px;`
+ );
+}
+function clearSmartGuides() {
+ for (const el of $('pageWrap').querySelectorAll('.smart-guide')) el.remove();
+}
+
+function drawGuides() {
+ const wrap = $('pageWrap');
+ for (const el of wrap.querySelectorAll('.guide')) el.remove();
+ if (!P()?.doc) return;
+ const H = P().pageHeight;
+ const mk = (vertical, coord) => {
+ const g = document.createElement('div');
+ g.className = 'guide';
+ g.style.cssText =
+ 'position:absolute;z-index:5;background:#00b3ff;opacity:.7;' +
+ (vertical
+ ? `left:${coord * state.zoom}px;top:0;width:1px;height:100%;cursor:col-resize;`
+ : `top:${(H - coord) * state.zoom}px;left:0;height:1px;width:100%;cursor:row-resize;`);
+ g.title = 'Drag to move, double-click to remove';
+ g.addEventListener('dblclick', () => {
+ const arr = vertical ? state.guides.v : state.guides.h;
+ const i = arr.indexOf(coord);
+ if (i >= 0) arr.splice(i, 1);
+ drawGuides();
+ });
+ g.addEventListener('mousedown', (e) => {
+ e.preventDefault();
+ e.stopPropagation();
+ const arr = vertical ? state.guides.v : state.guides.h;
+ const i = arr.indexOf(coord);
+ const move = (ev) => {
+ const r = $('page').getBoundingClientRect();
+ const val = vertical
+ ? (ev.clientX - r.left) / state.zoom
+ : H - (ev.clientY - r.top) / state.zoom;
+ if (i >= 0) arr[i] = val;
+ drawGuides();
+ };
+ const up = () => {
+ window.removeEventListener('mousemove', move);
+ window.removeEventListener('mouseup', up);
+ };
+ window.addEventListener('mousemove', move);
+ window.addEventListener('mouseup', up);
+ });
+ wrap.appendChild(g);
+ };
+ for (const x of state.guides.v) mk(true, x);
+ for (const y of state.guides.h) mk(false, y);
+}
+
+function drawRulers() {
+ const on = $('rulersChk').checked && P()?.doc;
+ let rh = $('rulerH'),
+ rv = $('rulerV');
+ if (!on) {
+ rh?.remove();
+ rv?.remove();
+ return;
+ }
+ const wrap = $('pageWrap');
+ const H = P().pageHeight,
+ W =
+ P().pageWidth ||
+ $('page').width / (state.zoom * (window.devicePixelRatio || 1));
+ const mkCanvas = (id) => {
+ let c = $(id);
+ if (!c) {
+ c = document.createElement('canvas');
+ c.id = id;
+ c.style.cssText =
+ 'position:absolute;z-index:6;background:rgba(245,245,245,.95);border:0;';
+ wrap.appendChild(c);
+ c.addEventListener('mousedown', (e) => {
+ e.preventDefault();
+ e.stopPropagation();
+ const vertical = id === 'rulerV';
+ const move = (ev) => {
+ const r = $('page').getBoundingClientRect();
+ const val = vertical
+ ? (ev.clientX - r.left) / state.zoom
+ : H - (ev.clientY - r.top) / state.zoom;
+ drag0 = val;
+ drawGuides();
+ };
+ let drag0 = null;
+ const arr = vertical ? state.guides.v : state.guides.h;
+ const up = (ev) => {
+ window.removeEventListener('mousemove', move);
+ window.removeEventListener('mouseup', up);
+ const r = $('page').getBoundingClientRect();
+ const val = vertical
+ ? (ev.clientX - r.left) / state.zoom
+ : H - (ev.clientY - r.top) / state.zoom;
+ if (val > 0) {
+ arr.push(val);
+ drawGuides();
+ }
+ };
+ window.addEventListener('mousemove', move);
+ window.addEventListener('mouseup', up);
+ });
+ }
+ return c;
+ };
+ rh = mkCanvas('rulerH');
+ rv = mkCanvas('rulerV');
+ const dpr = window.devicePixelRatio || 1;
+ const pw = $('page').getBoundingClientRect();
+ rh.width = pw.width * dpr;
+ rh.height = 16 * dpr;
+ rh.style.width = pw.width + 'px';
+ rh.style.height = '16px';
+ rh.style.left = '0px';
+ rh.style.top = '-18px';
+ rv.width = 16 * dpr;
+ rv.height = pw.height * dpr;
+ rv.style.width = '16px';
+ rv.style.height = pw.height + 'px';
+ rv.style.left = '-18px';
+ rv.style.top = '0px';
+ const tick = (ctx, len, horizontal) => {
+ ctx.scale(dpr, dpr);
+ ctx.strokeStyle = '#999';
+ ctx.fillStyle = '#666';
+ ctx.font = '8px system-ui';
+ ctx.beginPath();
+ for (let v = 0; v <= len; v += 9) {
+ const major = v % 72 === 0,
+ mid = v % 36 === 0;
+ const px = horizontal ? v * state.zoom : (H - v) * state.zoom;
+ const t = major ? 10 : mid ? 6 : 3;
+ if (horizontal) {
+ ctx.moveTo(px, 16);
+ ctx.lineTo(px, 16 - t);
+ } else {
+ ctx.moveTo(16, px);
+ ctx.lineTo(16 - t, px);
+ }
+ if (major && v) {
+ if (horizontal) ctx.fillText(String(v / 72), px + 2, 8);
+ else {
+ ctx.save();
+ ctx.translate(8, px - 2);
+ ctx.rotate(-Math.PI / 2);
+ ctx.fillText(String(v / 72), 0, 0);
+ ctx.restore();
+ }
+ }
+ }
+ ctx.stroke();
+ };
+ tick(rh.getContext('2d'), Math.max(W, 2000), true);
+ tick(rv.getContext('2d'), H, false);
+ drawGuides();
+}
+
+function updateChrome() {
+ const eng = P();
+ const has = eng && eng.doc;
+ $('save').disabled = !has;
+ $('save').classList.toggle('dirty', state.dirty);
+ $('undo').disabled = !state.undo.length;
+ $('redo').disabled = !state.redo.length;
+ $('pageLabel').textContent = has
+ ? `${eng.pageIndex + 1} / ${eng.pageCount}`
+ : '– / –';
+ $('zoomLabel').textContent = Math.round(state.zoom * 100) + '%';
+ const objSel = state.selection?.kind === 'object';
+ const paraSel = state.selection?.kind === 'para';
+ const multiSel = state.selection?.kind === 'multi';
+ for (const id of ['rotL', 'rotR'])
+ $(id).hidden = !(objSel || paraSel || multiSel);
+ for (const id of ['flipH', 'flipV', 'front', 'back']) $(id).hidden = !objSel;
+ $('replImg').hidden = !(objSel && state.selection.type === OBJ.IMAGE);
+ $('extEdit').hidden = !(
+ objSel &&
+ state.selection.type === OBJ.IMAGE &&
+ window.showSaveFilePicker
+ );
+ $('altText').hidden = !(
+ objSel &&
+ state.selection.type === OBJ.IMAGE &&
+ P().objectMcid?.(state.selection.handle) >= 0
+ );
+ const pathSel = objSel && state.selection.type === OBJ.PATH;
+ $('pStrokeRow').hidden = !pathSel;
+ if (pathSel) {
+ const st = P().pathStyle(state.selection.handle);
+ $('pStrokeW').value = (st.stroke ? st.strokeWidth : 0).toFixed(1);
+ $('pStrokeColor').value = rgbaToHex(st.strokeRgba >>> 0 || 255);
+ }
+ for (const id of ['dupe']) $(id).hidden = !(objSel || paraSel);
+ $('del').hidden = !(objSel || paraSel || multiSel);
+ const seps = document.querySelectorAll('#contextbar .objsep');
+ if (seps[0]) seps[0].hidden = !(objSel && state.selection.type === OBJ.IMAGE);
+ if (seps[1]) seps[1].hidden = !(objSel || paraSel || multiSel);
+ $('alignTools').hidden = !(multiSel && state.selection.items.length >= 2);
+
+ let info = '';
+ if (multiSel) {
+ info = state.selection.items.length + ' selected';
+ } else if (state.selection?.kind === 'object') {
+ const t =
+ ['', 'Text', 'Path', 'Image', 'Shading', 'Group'][state.selection.type] ||
+ 'Object';
+ info = t + ' object';
+ } else if (state.editing?.newGeom) {
+ info = 'New text box';
+ }
+ $('info').textContent = info;
+ syncInspector();
+}
+
+function syncInspector() {
+ const insp = $('inspector');
+ const p = state.editing?.para || state.selection?.para;
+ const enabled = (!!p || !!state.editing) && state.selection?.kind !== 'multi';
+ insp.toggleAttribute('disabled', !enabled);
+ if (!enabled) return;
+ let r = p?.runs[0] || {};
+ if (state.editing) {
+ const ed = state.editing.editable;
+ let span = null;
+ const sel = window.getSelection();
+ if (sel && sel.rangeCount) {
+ const rng = sel.getRangeAt(0);
+ let n = rng.startContainer;
+ if (ed.contains(n)) {
+ if (n.nodeType === 3 && rng.startOffset === 0 && n.previousSibling)
+ n = n.previousSibling;
+ if (n.nodeType !== 1) n = n.parentElement;
+ span = n?.closest?.('span[data-family]') || null;
+ }
+ }
+ span =
+ span || ed.querySelector('span[data-family]') || ed.querySelector('span');
+ if (span)
+ r = {
+ family: span.dataset.family,
+ size: parseFloat(span.dataset.size),
+ rgba: parseInt(span.dataset.rgba) >>> 0,
+ bold: parseInt(span.dataset.bold) >= 1,
+ italic: parseInt(span.dataset.italic) >= 1,
+ underline: span.dataset.underline === '1',
+ strike: span.dataset.strike === '1',
+ script: parseInt(span.dataset.script) || 0,
+ renderMode: parseInt(span.dataset.renderMode) || 0,
+ strokeRgba: (parseInt(span.dataset.strokeRgba) || 0) >>> 0,
+ strokeWidth: parseFloat(span.dataset.strokeWidth) || 1,
+ hScale: parseFloat(span.dataset.hScale) || 1,
+ };
+ }
+ setSelectValue($('fFamily'), r.family || 'Helvetica');
+ $('fSize').value = (r.size || 12).toFixed(0);
+ $('fColor').value = rgbaToHex(r.rgba >>> 0 || 255);
+ for (const b of $('fToggles').children) {
+ const t = b.dataset.t;
+ const on =
+ (t === 'bold' && r.bold) ||
+ (t === 'italic' && r.italic) ||
+ (t === 'underline' && r.underline) ||
+ (t === 'strike' && r.strike) ||
+ (t === 'sup' && r.script > 0) ||
+ (t === 'sub' && r.script < 0);
+ b.classList.toggle('on', !!on);
+ }
+ $('fStrokeW').value = (
+ r.renderMode === 1 || r.renderMode === 2 ? (r.strokeWidth ?? 1) : 0
+ ).toFixed(1);
+ $('fStrokeColor').value = rgbaToHex((r.strokeRgba || 0) >>> 0 || 255);
+ const fmt = p?.format || {
+ align: 0,
+ lineSpacing: 1.25,
+ paraSpacing: 0,
+ charSpacing: 0,
+ };
+ for (const b of $('fAlign').children)
+ b.classList.toggle('on', parseInt(b.dataset.a) === fmt.align);
+ $('fLine').value = (fmt.lineSpacing || 1.2).toFixed(2);
+ $('fPara').value = (fmt.paraSpacing || 0).toFixed(0);
+ $('fChar').value = (fmt.charSpacing || 0).toFixed(1);
+ $('fWordSp').value = (fmt.wordSpacing || 0).toFixed(1);
+ $('fHScale').value = Math.round((r.hScale || 1) * 100);
+ $('fFirstInd').value = (fmt.firstIndent || 0).toFixed(0);
+ $('fHangInd').value = (fmt.hangIndent || 0).toFixed(0);
+ for (const b of $('fDir').children)
+ b.classList.toggle('on', parseInt(b.dataset.d) === (fmt.dir || 0));
+}
+function setSelectValue(sel, val) {
+ if (![...sel.options].some((o) => o.value === val)) {
+ const o = document.createElement('option');
+ o.value = o.textContent = val;
+ sel.insertBefore(o, sel.firstChild);
+ }
+ sel.value = val;
+}
+
+function setTool(tool) {
+ endEdit(true);
+ state.tool = tool;
+ state.selection = null;
+ for (const b of document.querySelectorAll('[data-tool]'))
+ b.classList.toggle('on', b.dataset.tool === tool);
+ drawOverlay();
+ updateChrome();
+}
+
+let zoomFrame = 0;
+let zoomPending = false;
+function setZoom(z) {
+ endEdit(true);
+ state.zoom = Math.min(6, Math.max(0.2, z));
+ if (zoomFrame) {
+ zoomPending = true;
+ return;
+ }
+ renderPage();
+ updateChrome();
+ zoomFrame = requestAnimationFrame(() => {
+ zoomFrame = 0;
+ if (!zoomPending) return;
+ zoomPending = false;
+ renderPage();
+ updateChrome();
+ });
+}
+function getZoom() {
+ return state.zoom;
+}
+function getDocDescription() {
+ const eng = P();
+ const fields = {};
+ if (eng?.doc && eng.metaText) {
+ for (const k of [
+ 'Title',
+ 'Author',
+ 'Subject',
+ 'Keywords',
+ 'Creator',
+ 'Producer',
+ 'CreationDate',
+ 'ModDate',
+ ]) {
+ fields[k] = eng.metaText(k);
+ }
+ }
+ return {
+ meta: state.docMeta,
+ info: state.docInfo,
+ pdfa: state.pdfa,
+ fields,
+ pageSize: eng?.doc ? { w: eng.pageWidth, h: eng.pageHeight } : null,
+ };
+}
+function fitZoom() {
+ const stage = $('stage');
+ const avail = Math.min(stage.clientWidth - 48, 1400);
+ setZoom(avail / P().pageWidth);
+}
+function goToPage(i) {
+ const eng = P();
+ if (i < 0 || i >= eng.pageCount) return;
+ endEdit(true);
+ eng.generateContent();
+ eng.loadPage(i);
+ state.paragraphs = [];
+ state.selection = null;
+ renderPage();
+ updateChrome();
+ setTimeout(() => {
+ if (P().pageIndex !== i) return;
+ refreshModel();
+ drawOverlay();
+ updateChrome();
+ }, 0);
+}
+
+async function openFile(file, sourceUrl = null, knownBytes) {
+ endEdit(false);
+ if (state.extWatch) {
+ clearInterval(state.extWatch);
+ state.extWatch = null;
+ }
+ const otrace = new URLSearchParams(location.search).get('trace')
+ ? (m) => {
+ document.title = 'OPEN ' + m;
+ }
+ : () => {};
+ otrace('buf');
+ let bytes = knownBytes || new Uint8Array(await file.arrayBuffer());
+ try {
+ const cons = await consolidateContentArrays(bytes);
+ if (cons) bytes = cons;
+ } catch (e) {
+ console.warn('contents consolidation skipped:', e);
+ }
+ try {
+ const prot = await protectPatternArtwork(bytes);
+ if (prot) bytes = prot;
+ } catch (e) {
+ console.warn('pattern protection skipped:', e);
+ }
+ let t3seg = null;
+ try {
+ const t3 = await protectType3Text(bytes);
+ if (t3) {
+ bytes = t3.bytes;
+ t3seg = t3.seg;
+ }
+ } catch (e) {
+ console.warn('type3 protection skipped:', e);
+ }
+ let fragilePages = null;
+ try {
+ const fr = await protectFragileText(bytes);
+ if (fr) {
+ bytes = fr.bytes;
+ fragilePages = fr.pages;
+ }
+ } catch (e) {
+ console.warn('fragile-text protection skipped:', e);
+ }
+ otrace('engine.open ' + bytes.length);
+ try {
+ P().open(bytes);
+ } catch (e) {
+ if (e.passwordRequired) {
+ let pw = prompt('This PDF is password-protected.\nEnter the password:');
+ while (pw != null) {
+ try {
+ P().open(bytes, pw);
+ break;
+ } catch (e2) {
+ if (!e2.passwordRequired) {
+ toast(e2.message);
+ return;
+ }
+ pw = prompt('Wrong password. Try again:');
+ }
+ }
+ if (pw == null) return;
+ } else {
+ toast(e.message);
+ return;
+ }
+ }
+ otrace('opened; model');
+ if (t3seg) P().setType3Seg(t3seg);
+ if (fragilePages) P().setFragilePages(fragilePages);
+ state.notifiedSubs = new Set();
+ state.docInfo = P().documentInfo ? P().documentInfo() : null;
+ state.pdfa = PdfEngine.sniffPdfA ? PdfEngine.sniffPdfA(bytes) : false;
+ if (state.docInfo?.signatures > 0) {
+ toast(
+ 'This document is digitally signed — editing invalidates the ' +
+ 'signature. Saving appends a revision so the signed version stays verifiable.'
+ );
+ } else if (state.pdfa) {
+ toast(
+ 'This document declares PDF/A conformance — edits may break conformance.'
+ );
+ }
+ state.fileName = file.name.replace(/\.pdf$/i, '') + ' (edited).pdf';
+ state.docMeta = { name: file.name, size: bytes.length, source: sourceUrl };
+ const chip = $('docName');
+ chip.textContent = file.name;
+ chip.hidden = false;
+ state.undo = [];
+ state.redo = [];
+ state.dirty = false;
+ refreshModel();
+ otrace('model done');
+ state.selection = null;
+ $('empty').hidden = true;
+ $('pageWrap').hidden = false;
+ $('inspector').hidden = false;
+ fitZoom();
+ updateChrome();
+}
+
+async function saveFile() {
+ endEdit(true);
+ const incremental = (state.docInfo?.signatures || 0) > 0;
+ let bytes = await P().saveSpliced(
+ incremental ? { incremental: true } : undefined
+ );
+ if (!bytes) {
+ toast('Save failed.');
+ return;
+ }
+ try {
+ const newMarks = P().untaggedMarks();
+ const altText = state.pendingAlt || [];
+ if (newMarks.length || altText.length) {
+ bytes = applyTagSurgery(bytes, {
+ pageIndex: P().pageIndex,
+ newMarks,
+ altText,
+ });
+ state.pendingAlt = [];
+ }
+ } catch (e) {
+ console.warn('tag surgery skipped:', e);
+ }
+ const blob = new Blob([bytes], { type: 'application/pdf' });
+ const a = document.createElement('a');
+ a.href = URL.createObjectURL(blob);
+ a.download = state.fileName;
+ a.click();
+ URL.revokeObjectURL(a.href);
+ state.dirty = false;
+ const savedKb = (bytes.length / 1024) | 0;
+ if (onSaved) onSaved(savedKb, state.fileName);
+ else toast('Saved ' + savedKb + ' KB');
+}
+
+let onSaved = null;
+
+function setOnSaved(fn) {
+ onSaved = typeof fn === 'function' ? fn : null;
+}
+
+function wireUI() {
+ const fams = [
+ 'Helvetica',
+ 'Arial',
+ 'Times New Roman',
+ 'Georgia',
+ 'Courier New',
+ 'Verdana',
+ 'Trebuchet MS',
+ 'Tahoma',
+ 'Garamond',
+ 'Palatino',
+ ];
+ $('fFamily').append(
+ ...fams.map((f) => {
+ const o = document.createElement('option');
+ o.value = o.textContent = f;
+ return o;
+ })
+ );
+
+ const swatches = [
+ '#000000',
+ '#737373',
+ '#d1d1d1',
+ '#ed5924',
+ '#f2b705',
+ '#3b6ef5',
+ ];
+ for (const c of swatches) {
+ const d = document.createElement('div');
+ d.className = 'swatch';
+ d.style.background = c;
+ d.addEventListener('mousedown', (e) => e.preventDefault());
+ d.addEventListener('click', () =>
+ styleTargetRuns((s) => {
+ s.rgba = cssHexToRgba(c);
+ })
+ );
+ $('fSwatches').appendChild(d);
+ }
+
+ for (const b of [...$('fToggles').children, ...$('fAlign').children]) {
+ b.addEventListener('mousedown', (e) => e.preventDefault());
+ }
+
+ document.addEventListener('selectionchange', () => {
+ const e = state.editing;
+ if (!e) return;
+ const sel = window.getSelection();
+ if (!sel.rangeCount) return;
+ const r = sel.getRangeAt(0);
+ if (
+ !e.editable.contains(r.startContainer) ||
+ !e.editable.contains(r.endContainer)
+ )
+ return;
+ e.lastSel = sel.isCollapsed
+ ? null
+ : e.locked
+ ? lockedSelRange(e.editable)
+ : editorSelectionRange(e.editable);
+ });
+
+ $('file').addEventListener('change', (e) => {
+ if (e.target.files[0]) openFile(e.target.files[0]);
+ });
+ window.addEventListener('dragover', (e) => e.preventDefault());
+ window.addEventListener('drop', (e) => {
+ e.preventDefault();
+ const f = [...(e.dataTransfer?.files || [])].find(
+ (x) => x.type === 'application/pdf' || /\.pdf$/i.test(x.name)
+ );
+ if (f) openFile(f);
+ else if (e.dataTransfer?.files?.length) toast('Drop a PDF file.');
+ });
+ $('save').addEventListener('click', saveFile);
+ $('undo').addEventListener('click', () => restore(state.undo, state.redo));
+ $('redo').addEventListener('click', () => restore(state.redo, state.undo));
+ $('prev').addEventListener('click', () => goToPage(P().pageIndex - 1));
+ $('next').addEventListener('click', () => goToPage(P().pageIndex + 1));
+ $('zoomIn').addEventListener('click', () => setZoom(state.zoom * 1.25));
+ $('zoomOut').addEventListener('click', () => setZoom(state.zoom / 1.25));
+ $('zoomFit').addEventListener('click', fitZoom);
+ for (const b of document.querySelectorAll('[data-tool]')) {
+ b.addEventListener('click', () => setTool(b.dataset.tool));
+ }
+ $('editScope').addEventListener('change', () => {
+ state.editScope = $('editScope').value;
+ setTool('edit');
+ endEdit(true);
+ const sel = state.selection;
+ if (sel?.kind === 'para' && !scopeAllowsPara()) state.selection = null;
+ if (sel?.kind === 'object' && !scopeAllowsObj(sel.type))
+ state.selection = null;
+ if (sel?.kind === 'multi') state.selection = null;
+ drawOverlay();
+ updateChrome();
+ });
+
+ $('contextbar')?.addEventListener('mousedown', (e) => {
+ if (e.target.closest('button, .swatch, label')) e.preventDefault();
+ });
+
+ $('rotL').addEventListener('click', () => {
+ settleEditorForParaOp();
+ rotateSelection(90);
+ });
+ $('rotR').addEventListener('click', () => {
+ settleEditorForParaOp();
+ rotateSelection(-90);
+ });
+ $('flipH').addEventListener('click', () =>
+ objectOp((h) => P().flipObject(h, true))
+ );
+ $('flipV').addEventListener('click', () =>
+ objectOp((h) => P().flipObject(h, false))
+ );
+ const replaceImageFromBlob = async (handle, blobOrFile, label) => {
+ const bmp = await createImageBitmap(blobOrFile);
+ const cv = document.createElement('canvas');
+ cv.width = bmp.width;
+ cv.height = bmp.height;
+ const cx = cv.getContext('2d');
+ cx.drawImage(bmp, 0, 0);
+ const rgba = cx.getImageData(0, 0, cv.width, cv.height).data;
+ const bgra = new Uint8Array(rgba.length);
+ for (let i = 0; i < rgba.length; i += 4) {
+ bgra[i] = rgba[i + 2];
+ bgra[i + 1] = rgba[i + 1];
+ bgra[i + 2] = rgba[i];
+ bgra[i + 3] = rgba[i + 3];
+ }
+ snapshot();
+ if (P().replaceImage(handle, bgra, cv.width, cv.height)) {
+ scheduleRegen();
+ state.dirty = true;
+ renderPage();
+ drawOverlay();
+ updateChrome();
+ toast(label);
+ return true;
+ }
+ if (state.undo.length) state.undo.pop();
+ toast('Image replace failed.');
+ return false;
+ };
+ $('replImg').addEventListener('click', () => $('replImgFile').click());
+ $('replImgFile').addEventListener('change', async (e) => {
+ const file = e.target.files[0];
+ e.target.value = '';
+ if (
+ !file ||
+ state.selection?.kind !== 'object' ||
+ state.selection.type !== OBJ.IMAGE
+ )
+ return;
+ await replaceImageFromBlob(state.selection.handle, file, 'Image replaced.');
+ });
+ $('altText').addEventListener('click', () => {
+ const sel = state.selection;
+ if (sel?.kind !== 'object' || sel.type !== OBJ.IMAGE) return;
+ const mcid = P().objectMcid(sel.handle);
+ if (mcid < 0) {
+ toast('This image carries no structure tag.');
+ return;
+ }
+ const current =
+ (state.pendingAlt || []).find((a) => a.mcid === mcid)?.text ??
+ P().structAltFor(mcid) ??
+ '';
+ const text = prompt(
+ 'Alternate text for this image (screen readers):',
+ current
+ );
+ if (text === null) return;
+ state.pendingAlt = (state.pendingAlt || []).filter((a) => a.mcid !== mcid);
+ state.pendingAlt.push({ mcid, text });
+ state.dirty = true;
+ toast('Alt text staged — applies on Save.');
+ });
+
+ $('extEdit').addEventListener('click', async () => {
+ const sel = state.selection;
+ if (sel?.kind !== 'object' || sel.type !== OBJ.IMAGE) return;
+ const img = P().renderImageObject(sel.handle);
+ if (!img) {
+ toast('Could not export this image.');
+ return;
+ }
+ const cv = document.createElement('canvas');
+ cv.width = img.width;
+ cv.height = img.height;
+ cv.getContext('2d').putImageData(
+ new ImageData(img.data, img.width, img.height),
+ 0,
+ 0
+ );
+ const blob = await new Promise((res) => cv.toBlob(res, 'image/png'));
+ let fh;
+ try {
+ fh = await showSaveFilePicker({
+ suggestedName: 'pdf-image.png',
+ types: [
+ { description: 'PNG image', accept: { 'image/png': ['.png'] } },
+ ],
+ });
+ } catch {
+ return;
+ }
+ const w = await fh.createWritable();
+ await w.write(blob);
+ await w.close();
+ let lastSeen = (await fh.getFile()).lastModified;
+ toast('Exported. Edit + save the PNG in any app — changes re-import live.');
+ if (state.extWatch) clearInterval(state.extWatch);
+ const handle = sel.handle;
+ state.extWatch = setInterval(async () => {
+ try {
+ const f = await fh.getFile();
+ if (f.lastModified === lastSeen) return;
+ lastSeen = f.lastModified;
+ await replaceImageFromBlob(handle, f, 'External edit re-imported.');
+ } catch {
+ clearInterval(state.extWatch);
+ state.extWatch = null;
+ }
+ }, 1500);
+ });
+ $('pStrokeColor').addEventListener('change', () => applyPathStroke());
+ $('pStrokeW').addEventListener('change', () => applyPathStroke());
+ $('front').addEventListener('click', () => arrangeSel('front'));
+ $('back').addEventListener('click', () => arrangeSel('back'));
+ $('dupe').addEventListener('click', () => {
+ settleEditorForParaOp();
+ duplicateSelection();
+ });
+ $('addImage').addEventListener('click', () => $('imgFile').click());
+ $('imgFile').addEventListener('change', (e) => {
+ if (e.target.files[0]) addImageFromFile(e.target.files[0]);
+ e.target.value = '';
+ });
+ $('del').addEventListener('click', () => {
+ settleEditorForParaOp(false);
+ if (state.selection?.kind === 'object') {
+ snapshotEdit('delete');
+ P().historyRemoveObject(state.selection.handle);
+ state.selection = null;
+ state.dirty = true;
+ refreshAfterMutation();
+ } else if (state.selection?.kind === 'para') {
+ snapshotEdit('delete text');
+ P().deleteParagraph(state.selection.para.id);
+ replaceParagraph(state.selection.para.id, null);
+ state.selection = null;
+ state.dirty = true;
+ refreshAfterMutation();
+ } else if (state.selection?.kind === 'multi') {
+ snapshot();
+ for (const it of state.selection.items) {
+ if (it.t === 'para') {
+ P().deleteParagraph(it.para.id);
+ replaceParagraph(it.para.id, null);
+ } else P().removeObject(it.handle);
+ }
+ state.selection = null;
+ state.dirty = true;
+ refreshAfterMutation();
+ }
+ });
+
+ $('fFamily').addEventListener('change', async (e) => {
+ const fam = e.target.value;
+ await ensureLocalFontBytes(fam);
+ styleTargetRuns((s) => {
+ s.family = fam;
+ });
+ });
+ $('docName').addEventListener('click', () => {
+ const m = state.docMeta;
+ if (!m) return;
+ const kb = (m.size / 1024).toFixed(0);
+ toast(
+ m.source
+ ? `${m.name} — ${kb} KB — loaded from ${new URL(m.source, location.href).href}`
+ : `${m.name} — ${kb} KB — chosen from your device (browsers hide the folder path; it stays wherever you picked it)`
+ );
+ });
+ $('sysFonts').addEventListener('click', () => loadSystemFonts(false));
+ if (!new URLSearchParams(location.search).get('noauto'))
+ loadSystemFonts(true);
+ if ('serviceWorker' in navigator && location.protocol === 'https:') {
+ navigator.serviceWorker
+ .register('sw.js', { updateViaCache: 'none' })
+ .then((reg) => reg.update().catch(() => {}))
+ .catch(() => {});
+ }
+ window.addEventListener(
+ 'pointerdown',
+ () => {
+ if (!PdfEngine.localFonts.size) loadSystemFonts(true);
+ },
+ { once: true }
+ );
+ for (const b of $('fList').children)
+ b.addEventListener('click', () => toggleListPrefix(b.dataset.l));
+ for (const b of $('alignTools').children)
+ b.addEventListener('click', () => alignSelection(b.dataset.al));
+ $('copyText').addEventListener('click', async () => {
+ if (!P()?.doc) return;
+ const t = P().pageTextJson();
+ if (!t || !t.text) {
+ toast('No text on this page.');
+ return;
+ }
+ try {
+ await navigator.clipboard.writeText(t.text);
+ const furniture = t.blocks.filter(
+ (b) => b.role === 'header' || b.role === 'footer'
+ ).length;
+ toast(
+ `Copied ${t.blocks.length} blocks in reading order` +
+ (furniture ? ` (${furniture} header/footer)` : '')
+ );
+ } catch {
+ toast('Clipboard permission denied.');
+ }
+ });
+
+ $('spellPage').addEventListener('click', () => {
+ spellStart();
+ });
+ $('spellNext').addEventListener('click', () => spellNext());
+ $('spellChange').addEventListener('click', () => spellChange());
+ $('spellIgnore').addEventListener('click', () => {
+ const hit = spell.list[spell.at];
+ if (hit) spell.ignored.add(hit.word.toLowerCase());
+ spellRescan();
+ spellNext();
+ });
+ $('spellAdd').addEventListener('click', () => {
+ const hit = spell.list[spell.at];
+ if (!hit) return;
+ userDictionaryAdd(hit.word);
+ P().spellLoad(new TextEncoder().encode(hit.word.toLowerCase() + '\n'));
+ spellRescan();
+ spellNext();
+ });
+ $('spellDone').addEventListener('click', () => {
+ $('spellbar').hidden = true;
+ document.querySelectorAll('.spellhit').forEach((el) => el.remove());
+ });
+
+ $('spellchk').addEventListener('change', (e) => {
+ const ed = state.editing?.editable;
+ if (ed) {
+ ed.spellcheck = e.target.checked;
+ ed.blur();
+ ed.focus();
+ }
+ });
+ $('fStrokeW').addEventListener('change', (e) => {
+ const w = Math.max(0, parseFloat(e.target.value) || 0);
+ const rgba = cssHexToRgba($('fStrokeColor').value || '#000000');
+ styleTargetRuns((s) => {
+ s.strokeWidth = w;
+ if (w > 0) {
+ s.renderMode = 2;
+ s.strokeRgba = rgba;
+ } else if (
+ (parseInt(s.renderMode) || 0) === 2 ||
+ (parseInt(s.renderMode) || 0) === 1
+ )
+ s.renderMode = 0;
+ });
+ });
+ $('fStrokeColor').addEventListener('input', (e) => {
+ const rgba = cssHexToRgba(e.target.value);
+ styleTargetRuns((s) => {
+ s.strokeRgba = rgba;
+ if (
+ (parseFloat(s.strokeWidth) || 0) > 0 &&
+ (parseInt(s.renderMode) || 0) === 0
+ )
+ s.renderMode = 2;
+ });
+ });
+ $('fSize').addEventListener('change', (e) =>
+ styleTargetRuns((s) => {
+ s.size = parseFloat(e.target.value);
+ })
+ );
+ $('fColor').addEventListener('input', (e) =>
+ styleTargetRuns((s) => {
+ s.rgba = cssHexToRgba(e.target.value);
+ })
+ );
+ for (const b of $('fToggles').children)
+ b.addEventListener('click', () => toggleStyle(b.dataset.t));
+ for (const b of $('fAlign').children)
+ b.addEventListener('click', () =>
+ changeFormat((f) => {
+ f.align = parseInt(b.dataset.a);
+ })
+ );
+ $('fLine').addEventListener('change', (e) =>
+ changeFormat((f) => {
+ f.lineSpacing = parseFloat(e.target.value);
+ })
+ );
+ $('fPara').addEventListener('change', (e) =>
+ changeFormat((f) => {
+ f.paraSpacing = parseFloat(e.target.value);
+ })
+ );
+ $('fChar').addEventListener('change', (e) =>
+ changeFormat((f) => {
+ f.charSpacing = parseFloat(e.target.value);
+ })
+ );
+ $('fWordSp').addEventListener('change', (e) =>
+ changeFormat((f) => {
+ f.wordSpacing = parseFloat(e.target.value);
+ })
+ );
+ $('fFirstInd').addEventListener('change', (e) =>
+ changeFormat((f) => {
+ f.firstIndent = Math.max(0, parseFloat(e.target.value) || 0);
+ })
+ );
+ $('fHangInd').addEventListener('change', (e) =>
+ changeFormat((f) => {
+ f.hangIndent = Math.max(0, parseFloat(e.target.value) || 0);
+ })
+ );
+ for (const b of $('fDir').children) {
+ b.addEventListener('click', () =>
+ changeFormat((f) => {
+ f.dir = parseInt(b.dataset.d);
+ })
+ );
+ }
+ for (const b of $('fLevel').children) {
+ b.addEventListener('click', () => adjustListLevel(parseInt(b.dataset.lv)));
+ }
+ $('fMarkerStyle').addEventListener('change', (e) =>
+ setListMarkerStyle(e.target.value)
+ );
+ $('rulersChk').addEventListener('change', drawRulers);
+ wireBlockClipboard();
+ $('spellLang').addEventListener('change', () => {
+ const ed = state.editing?.editable;
+ if (ed) ed.lang = $('spellLang').value || '';
+ });
+ $('fHScale').addEventListener('change', (e) =>
+ styleTargetRuns((s) => {
+ s.hScale = parseFloat(e.target.value) / 100;
+ })
+ );
+
+ $('find').addEventListener('click', () => {
+ $('findbar').hidden = !$('findbar').hidden;
+ if (!$('findbar').hidden) $('findText').focus();
+ });
+ $('findDone').addEventListener('click', () => ($('findbar').hidden = true));
+ const findReport = (r) => {
+ $('findStatus').textContent = r
+ ? `${r.i + 1} of ${r.total} on page ${r.page + 1}: ${r.text}`
+ : 'Not found';
+ };
+ const findGo = (dir) => {
+ const n = $('findText').value;
+ if (!n) return;
+ findReport(findStep(n, dir));
+ };
+ $('findNext').addEventListener('click', () => findGo(1));
+ $('findPrev').addEventListener('click', () => findGo(-1));
+ $('findText').addEventListener('keydown', (e) => {
+ if (e.key === 'Enter') {
+ e.preventDefault();
+ findGo(e.shiftKey ? -1 : 1);
+ }
+ });
+ $('replOne').addEventListener('click', () => {
+ const n = $('findText').value;
+ if (!n) return;
+ if (!state.find || state.find.i < 0) findStep(n, 1);
+ const done = replaceCurrent(n, $('replText').value);
+ $('findStatus').textContent = done ? 'Replaced 1' : 'Not found';
+ if (done) findReport(findStep(n, 1));
+ });
+ $('replAll').addEventListener('click', () => {
+ const n = $('findText').value;
+ if (!n) return;
+ const c = replaceAll(n, $('replText').value);
+ $('findStatus').textContent = 'Replaced ' + c;
+ });
+
+ stagePointHandlers();
+
+ window.addEventListener('keydown', (e) => {
+ const meta = e.metaKey || e.ctrlKey;
+ const typing =
+ document.activeElement &&
+ (['INPUT', 'SELECT'].includes(document.activeElement.tagName) ||
+ state.editing);
+ if (meta && e.key.toLowerCase() === 's') {
+ e.preventDefault();
+ if (P().doc) saveFile();
+ } else if (meta && e.key.toLowerCase() === 'z' && !e.shiftKey) {
+ e.preventDefault();
+ restore(state.undo, state.redo);
+ } else if (
+ meta &&
+ (e.key.toLowerCase() === 'y' ||
+ (e.key.toLowerCase() === 'z' && e.shiftKey))
+ ) {
+ e.preventDefault();
+ restore(state.redo, state.undo);
+ } else if (meta && e.key.toLowerCase() === 'f') {
+ e.preventDefault();
+ $('findbar').hidden = false;
+ $('findText').focus();
+ } else if (
+ (e.key === 'Backspace' || e.key === 'Delete') &&
+ !typing &&
+ state.selection
+ ) {
+ e.preventDefault();
+ $('del').click();
+ } else if (e.key.startsWith('Arrow') && !typing && state.selection) {
+ e.preventDefault();
+ const step = e.shiftKey ? 10 : 1;
+ const dx =
+ e.key === 'ArrowLeft' ? -step : e.key === 'ArrowRight' ? step : 0;
+ const dy = e.key === 'ArrowUp' ? step : e.key === 'ArrowDown' ? -step : 0;
+ nudgeSelection(dx, dy);
+ }
+ });
+
+ window.addEventListener('beforeunload', (e) => {
+ if (state.dirty) {
+ e.preventDefault();
+ e.returnValue = '';
+ }
+ });
+
+ updateChrome();
+}
+
+function nudgeSelection(dx, dy) {
+ snapshotEdit('nudge');
+ if (state.selection?.kind === 'object') {
+ noteMatrix(state.selection.handle);
+ P().translateObject(state.selection.handle, dx, dy);
+ state.selection.bounds = P().objectBounds(state.selection.handle);
+ state.dirty = true;
+ } else if (state.selection?.kind === 'para') {
+ const p = state.selection.para;
+ if (P().moveParagraph(p.id, dx, dy)) {
+ const dT = pageToText(p, dx, dy);
+ const moved = {
+ ...p,
+ box: { ...p.box, x: p.box.x + dT.x, top: p.box.top + dT.y },
+ };
+ replaceParagraph(p.id, moved);
+ state.selection = { kind: 'para', para: moved };
+ state.dirty = true;
+ }
+ } else if (state.selection?.kind === 'multi') {
+ for (const it of state.selection.items) moveItem(it, dx, dy);
+ state.dirty = true;
+ } else {
+ if (state.undo.length) state.undo.pop();
+ return;
+ }
+ refreshAfterMutation();
+}
+
+function hasRealVariant(family, bold, italic) {
+ const std14 = [
+ 'helvetica',
+ 'arial',
+ 'times',
+ 'times new roman',
+ 'courier',
+ 'courier new',
+ 'symbol',
+ 'zapfdingbats',
+ ];
+ if (std14.includes((family || '').toLowerCase())) return true;
+ const styles = localFontMeta.get(family);
+ if (!styles) return false;
+ const key = (bold ? 'b' : '') + (italic ? 'i' : '');
+ return styles.has(key || 'r') || styles.has(key);
+}
+
+function toggleStyle(t) {
+ styleTargetRuns((s) => {
+ const cur = (k) => s[k] == 1 || s[k] === true || s[k] === 2;
+ if (t === 'bold') s.bold = cur('bold') ? 0 : 1;
+ else if (t === 'italic') s.italic = cur('italic') ? 0 : 1;
+ else if (t === 'underline') s.underline = cur('underline') ? 0 : 1;
+ else if (t === 'strike') s.strike = cur('strike') ? 0 : 1;
+ else if (t === 'sup') s.script = (parseInt(s.script) || 0) === 1 ? 0 : 1;
+ else if (t === 'sub') s.script = (parseInt(s.script) || 0) === -1 ? 0 : -1;
+ });
+}
+
+function settleEditorForParaOp(commit = true) {
+ if (!state.editing) return;
+ const para = state.editing.para;
+ endEdit(commit);
+ if (!state.selection && para) {
+ const again =
+ state.paragraphs.find((p) => p.id === para.id) ||
+ state.paragraphs.find(
+ (p) =>
+ Math.abs(p.box.x - para.box.x) < 1 &&
+ Math.abs(p.box.top - para.box.top) < 1
+ );
+ if (again) state.selection = { kind: 'para', para: again };
+ }
+}
+
+function rotateSelection(deg) {
+ const sel = state.selection;
+ if (sel?.kind === 'object') {
+ objectOp((h) => P().rotateObject(h, deg));
+ return;
+ }
+ let handles = [],
+ cx,
+ cy;
+ if (sel?.kind === 'para') {
+ const b = sel.para.box;
+ const c = textToPage(sel.para, b.x + b.w / 2, b.top - b.h / 2);
+ cx = c.x;
+ cy = c.y;
+ handles = P().paragraphObjects(sel.para.id);
+ } else if (sel?.kind === 'multi') {
+ let x0 = 1e9,
+ y0 = 1e9,
+ x1 = -1e9,
+ y1 = -1e9;
+ for (const it of sel.items) {
+ const e = itemEnvelope(it);
+ x0 = Math.min(x0, e.x);
+ y0 = Math.min(y0, e.y);
+ x1 = Math.max(x1, e.x + e.w);
+ y1 = Math.max(y1, e.y + e.h);
+ }
+ cx = (x0 + x1) / 2;
+ cy = (y0 + y1) / 2;
+ for (const it of sel.items) {
+ if (it.t === 'obj') handles.push(it.handle);
+ else handles.push(...P().paragraphObjects(it.para.id));
+ }
+ }
+ if (!handles.length) return;
+ const keepPara = sel?.kind === 'para' ? sel.para : null;
+ snapshotEdit('rotate');
+ for (const h of handles) noteMatrix(h);
+ P().rotateObjectsAbout(handles, deg, cx, cy);
+ state.dirty = true;
+ refreshModel();
+ state.selection = null;
+ if (keepPara) {
+ const want = keepPara.runs.map((r) => r.text).join('');
+ const again =
+ state.paragraphs.find((p) => p.id === keepPara.id) ||
+ (want
+ ? state.paragraphs.find(
+ (p) => p.runs.map((r) => r.text).join('') === want
+ )
+ : null);
+ if (again) state.selection = { kind: 'para', para: again };
+ }
+ renderPage();
+ updateChrome();
+}
+
+function objectOp(fn, note) {
+ if (state.selection?.kind !== 'object') return;
+ snapshotEdit('transform');
+ noteMatrix(state.selection.handle);
+ fn(state.selection.handle);
+ state.selection.bounds = P().objectBounds(state.selection.handle);
+ state.dirty = true;
+ refreshAfterMutation();
+ if (note) toast(note);
+}
+
+function arrangeSel(op) {
+ if (state.selection?.kind !== 'object') return;
+ snapshotEdit('arrange');
+ noteZOrder(state.selection.handle);
+ if (P().arrangeObject(state.selection.handle, op)) {
+ state.dirty = true;
+ refreshAfterMutation();
+ } else if (state.undo.length) state.undo.pop();
+}
+
+function duplicateSelection() {
+ if (state.selection?.kind === 'object') {
+ if (state.selection.type !== OBJ.IMAGE) {
+ toast('Duplicate currently supports images and text boxes.');
+ return;
+ }
+ snapshotEdit('duplicate');
+ const clone = P().duplicateImage(state.selection.handle, 12, -12);
+ if (clone) {
+ noteInsert(clone);
+ state.selection = {
+ kind: 'object',
+ handle: clone,
+ type: OBJ.IMAGE,
+ bounds: P().objectBounds(clone),
+ };
+ state.dirty = true;
+ refreshAfterMutation();
+ } else if (state.undo.length) state.undo.pop();
+ } else if (state.selection?.kind === 'para') {
+ const p = state.selection.para;
+ snapshotEdit('duplicate text');
+ const created = P().duplicateParagraph(p.id, 12, -12);
+ if (created) {
+ state.paragraphs.push(created);
+ state.selection = { kind: 'para', para: created };
+ state.dirty = true;
+ refreshAfterMutation();
+ } else {
+ if (state.undo.length) state.undo.pop();
+ toast("This text block can't be duplicated.");
+ }
+ }
+}
+
+const localFontMeta = new Map();
+
+async function loadSystemFonts(silent = false) {
+ if (!window.queryLocalFonts) {
+ if (!silent)
+ toast('System font access needs Chrome/Edge (Local Font Access API).');
+ return;
+ }
+ try {
+ const fonts = await window.queryLocalFonts();
+ for (const f of fonts) {
+ const b = /bold|black|heavy|semi ?bold|extra ?bold/i.test(f.style);
+ const i = /italic|oblique/i.test(f.style);
+ const key = (b ? '1' : '0') + (i ? '1' : '0');
+ let styles = localFontMeta.get(f.family);
+ if (!styles) {
+ styles = new Map();
+ localFontMeta.set(f.family, styles);
+ }
+ const cur = styles.get(key);
+ const exact = /^(regular|bold|italic|bold italic|oblique)$/i.test(
+ f.style
+ );
+ if (
+ !cur ||
+ (exact &&
+ !/^(regular|bold|italic|bold italic|oblique)$/i.test(cur.style))
+ ) {
+ styles.set(key, f);
+ }
+ }
+ preloadScriptFonts();
+ const sel = $('fFamily');
+ const cur = sel.value;
+ const fams = [...localFontMeta.keys()].sort();
+ sel.replaceChildren(
+ ...fams.map((f) => {
+ const o = document.createElement('option');
+ o.value = o.textContent = f;
+ return o;
+ })
+ );
+ setSelectValue(sel, cur);
+ if (!silent) toast(fams.length + ' system font families available');
+ } catch (err) {
+ if (!silent) toast('Font access denied: ' + err.message);
+ }
+}
+
+async function ensureLocalFontBytes(family) {
+ const styles = localFontMeta.get(family);
+ if (!styles || PdfEngine.localFonts.has(family)) return;
+ const MAX = 40 * 1024 * 1024;
+ await Promise.allSettled(
+ [...styles.entries()].map(async ([key, meta]) => {
+ try {
+ const blob = await meta.blob();
+ if (blob.size > MAX) return;
+ const bytes = new Uint8Array(await blob.arrayBuffer());
+ PdfEngine.localFonts.set(family + '|' + key, bytes);
+ if (key === '00' || !PdfEngine.localFonts.has(family))
+ PdfEngine.localFonts.set(family, bytes);
+ } catch {}
+ })
+ );
+}
+
+const SCRIPT_FONT_CANDIDATES = [
+ 'Geeza Pro',
+ 'Al Nile',
+ 'Damascus',
+ 'Baghdad',
+ 'Noto Naskh Arabic',
+ 'Noto Sans Arabic',
+ 'Arial Unicode MS',
+ 'Arial Hebrew',
+ 'Lucida Grande',
+ 'Kohinoor Devanagari',
+ 'Devanagari MT',
+ 'ITF Devanagari',
+ 'Kohinoor Bangla',
+ 'Bangla MN',
+ 'Gurmukhi MN',
+ 'Kohinoor Gujarati',
+ 'Gujarati MT',
+ 'Tamil MN',
+ 'InaiMathi',
+ 'Kohinoor Telugu',
+ 'Telugu MN',
+ 'Kannada MN',
+ 'Malayalam MN',
+ 'Thonburi',
+ 'Sukhumvit Set',
+ 'Apple SD Gothic Neo',
+ 'Hiragino Sans',
+ 'PingFang SC',
+];
+function preloadScriptFonts() {
+ for (const fam of SCRIPT_FONT_CANDIDATES)
+ if (localFontMeta.has(fam)) ensureLocalFontBytes(fam);
+}
+
+async function addImageFromFile(file) {
+ const bitmap = await createImageBitmap(file);
+ const c = document.createElement('canvas');
+ c.width = bitmap.width;
+ c.height = bitmap.height;
+ const cx = c.getContext('2d');
+ cx.drawImage(bitmap, 0, 0);
+ const rgba = cx.getImageData(0, 0, c.width, c.height).data;
+ const maxW = 240,
+ w = Math.min(maxW, bitmap.width),
+ h = w * (bitmap.height / bitmap.width);
+ const x = P().pageWidth / 2 - w / 2,
+ y = P().pageHeight / 2 - h / 2;
+ snapshotEdit('insert image');
+ const handle = P().insertImage(
+ new Uint8Array(rgba),
+ c.width,
+ c.height,
+ x,
+ y,
+ w,
+ h
+ );
+ if (handle) {
+ noteInsert(handle);
+ if (P().pageIsTagged()) P().tagObject(handle, 'Figure');
+ state.selection = {
+ kind: 'object',
+ handle,
+ type: OBJ.IMAGE,
+ bounds: P().objectBounds(handle),
+ };
+ state.dirty = true;
+ setTool('edit');
+ refreshAfterMutation();
+ toast('Image added');
+ } else if (state.undo.length) state.undo.pop();
+}
+
+export {
+ openFile,
+ engineReady,
+ setZoom,
+ getZoom,
+ getDocDescription,
+ setOnSaved,
+};
diff --git a/src/js/editcore/core.js b/src/js/editcore/core.js
new file mode 100644
index 00000000..e6e24d37
--- /dev/null
+++ b/src/js/editcore/core.js
@@ -0,0 +1,2017 @@
+import { createEngineModule, ENGINE_BUILD } from './engine-loader.js';
+
+export const EC_BUILD = ENGINE_BUILD;
+const createEditCore = createEngineModule;
+const devBust = '';
+
+import { applyType3LeftoverSurgery } from './type3surgery.js';
+import { probePdfBytes } from './shadingsurgery.js';
+
+const RUN_SIZE = 60;
+const FMT_SIZE = 36;
+
+export const OBJ = {
+ UNKNOWN: 0,
+ TEXT: 1,
+ PATH: 2,
+ IMAGE: 3,
+ SHADING: 4,
+ FORM: 5,
+};
+
+const sfntCoverMemo = new WeakMap();
+
+export function sfntCovers(bytes, cps) {
+ let per = sfntCoverMemo.get(bytes);
+ if (!per) {
+ per = new Map();
+ sfntCoverMemo.set(bytes, per);
+ }
+ const key = cps.length ? cps.join(',') : '';
+ const hit = per.get(key);
+ if (hit !== undefined) return hit;
+ const computed = sfntCoversUncached(bytes, cps);
+ if (per.size < 512) per.set(key, computed);
+ return computed;
+}
+
+function sfntCoversUncached(bytes, cps) {
+ try {
+ const u16 = (o) => (bytes[o] << 8) | bytes[o + 1];
+ const u32 = (o) =>
+ ((bytes[o] << 24) |
+ (bytes[o + 1] << 16) |
+ (bytes[o + 2] << 8) |
+ bytes[o + 3]) >>>
+ 0;
+ let base = 0;
+ if (u32(0) === 0x74746366) base = u32(12);
+ const num = u16(base + 4);
+ let cmap = 0;
+ for (let i = 0; i < num; i++) {
+ const o = base + 12 + i * 16;
+ if (u32(o) === 0x636d6170) {
+ cmap = u32(o + 8);
+ break;
+ }
+ }
+ if (!cmap) return false;
+ const nt = u16(cmap + 2);
+ let sub = 0,
+ fmt = -1,
+ score = -1;
+ for (let i = 0; i < nt; i++) {
+ const rec = cmap + 4 + i * 8;
+ const pid = u16(rec),
+ eid = u16(rec + 2),
+ off = cmap + u32(rec + 4);
+ const f = u16(off);
+ const sc =
+ pid === 3 && eid === 10
+ ? 3
+ : pid === 3 && eid === 1
+ ? 2
+ : pid === 0
+ ? 1
+ : 0;
+ if (sc > score && (f === 4 || f === 12)) {
+ score = sc;
+ sub = off;
+ fmt = f;
+ }
+ }
+ if (!sub) return false;
+ const lookup4 = (cp) => {
+ if (cp > 0xffff) return 0;
+ const segX2 = u16(sub + 6);
+ for (let s2 = 0; s2 < segX2; s2 += 2) {
+ const end = u16(sub + 14 + s2);
+ if (cp <= end) {
+ const start = u16(sub + 16 + segX2 + s2);
+ if (cp < start) return 0;
+ const delta = u16(sub + 16 + segX2 * 2 + s2);
+ const roBase = sub + 16 + segX2 * 3 + s2;
+ const ro = u16(roBase);
+ if (!ro) return (cp + delta) & 0xffff;
+ const g = u16(roBase + ro + (cp - start) * 2);
+ return g ? (g + delta) & 0xffff : 0;
+ }
+ }
+ return 0;
+ };
+ const lookup12 = (cp) => {
+ const n = u32(sub + 12);
+ let lo = 0,
+ hi = n - 1;
+ while (lo <= hi) {
+ const mid = (lo + hi) >> 1,
+ g = sub + 16 + mid * 12;
+ const s0 = u32(g),
+ e0 = u32(g + 4);
+ if (cp < s0) hi = mid - 1;
+ else if (cp > e0) lo = mid + 1;
+ else return u32(g + 8) + (cp - s0);
+ }
+ return 0;
+ };
+ for (const cp of cps) {
+ if (cp === 0x20 || cp === 0x0a || cp === 0x0d) continue;
+ if ((fmt === 12 ? lookup12(cp) : lookup4(cp)) === 0) return false;
+ }
+ return true;
+ } catch {
+ return false;
+ }
+}
+
+export function scriptFallbackFamilies(cps) {
+ const TABLE = [
+ [
+ [
+ [0x0600, 0x06ff],
+ [0x0750, 0x077f],
+ [0xfb50, 0xfdff],
+ [0xfe70, 0xfeff],
+ ],
+ [
+ 'Geeza Pro',
+ 'Al Nile',
+ 'Damascus',
+ 'Baghdad',
+ 'Noto Naskh Arabic',
+ 'Noto Sans Arabic',
+ 'Arial Unicode MS',
+ 'Arial',
+ ],
+ ],
+ [
+ [
+ [0x0590, 0x05ff],
+ [0xfb1d, 0xfb4f],
+ ],
+ [
+ 'Arial Hebrew',
+ 'Lucida Grande',
+ 'Noto Sans Hebrew',
+ 'Arial Unicode MS',
+ 'Arial',
+ ],
+ ],
+ [
+ [[0x0900, 0x097f]],
+ [
+ 'Kohinoor Devanagari',
+ 'Devanagari MT',
+ 'ITF Devanagari',
+ 'Nirmala UI',
+ 'Mangal',
+ 'Noto Sans Devanagari',
+ 'Arial Unicode MS',
+ ],
+ ],
+ [
+ [[0x0980, 0x09ff]],
+ [
+ 'Kohinoor Bangla',
+ 'Bangla MN',
+ 'Nirmala UI',
+ 'Vrinda',
+ 'Noto Sans Bengali',
+ 'Arial Unicode MS',
+ ],
+ ],
+ [
+ [[0x0a00, 0x0a7f]],
+ [
+ 'Gurmukhi MN',
+ 'Nirmala UI',
+ 'Raavi',
+ 'Noto Sans Gurmukhi',
+ 'Arial Unicode MS',
+ ],
+ ],
+ [
+ [[0x0a80, 0x0aff]],
+ [
+ 'Kohinoor Gujarati',
+ 'Gujarati MT',
+ 'Nirmala UI',
+ 'Shruti',
+ 'Noto Sans Gujarati',
+ 'Arial Unicode MS',
+ ],
+ ],
+ [
+ [[0x0b80, 0x0bff]],
+ [
+ 'Tamil MN',
+ 'InaiMathi',
+ 'Nirmala UI',
+ 'Latha',
+ 'Noto Sans Tamil',
+ 'Arial Unicode MS',
+ ],
+ ],
+ [
+ [[0x0c00, 0x0c7f]],
+ [
+ 'Kohinoor Telugu',
+ 'Telugu MN',
+ 'Nirmala UI',
+ 'Gautami',
+ 'Noto Sans Telugu',
+ 'Arial Unicode MS',
+ ],
+ ],
+ [
+ [[0x0c80, 0x0cff]],
+ [
+ 'Kannada MN',
+ 'Nirmala UI',
+ 'Tunga',
+ 'Noto Sans Kannada',
+ 'Arial Unicode MS',
+ ],
+ ],
+ [
+ [[0x0d00, 0x0d7f]],
+ [
+ 'Malayalam MN',
+ 'Nirmala UI',
+ 'Kartika',
+ 'Noto Sans Malayalam',
+ 'Arial Unicode MS',
+ ],
+ ],
+ [
+ [[0x0e00, 0x0e7f]],
+ [
+ 'Thonburi',
+ 'Sukhumvit Set',
+ 'Leelawadee UI',
+ 'Tahoma',
+ 'Noto Sans Thai',
+ 'Arial Unicode MS',
+ ],
+ ],
+ [
+ [
+ [0x3040, 0x30ff],
+ [0x3400, 0x9fff],
+ [0xf900, 0xfaff],
+ ],
+ [
+ 'PingFang SC',
+ 'Hiragino Sans',
+ 'Hiragino Sans GB',
+ 'Songti SC',
+ 'STHeiti',
+ 'Apple SD Gothic Neo',
+ 'Microsoft YaHei',
+ 'Yu Gothic',
+ 'Meiryo',
+ 'SimSun',
+ 'MS Gothic',
+ 'Malgun Gothic',
+ 'Noto Sans CJK SC',
+ 'Noto Sans SC',
+ 'Arial Unicode MS',
+ ],
+ ],
+ [
+ [[0xac00, 0xd7af]],
+ [
+ 'Apple SD Gothic Neo',
+ 'AppleGothic',
+ 'Malgun Gothic',
+ 'Batang',
+ 'Noto Sans KR',
+ 'Noto Sans CJK KR',
+ 'Arial Unicode MS',
+ ],
+ ],
+ [
+ [[0x0b00, 0x0b7f]],
+ ['Oriya MN', 'Nirmala UI', 'Kalinga', 'Noto Sans Oriya'],
+ ],
+ [
+ [[0x0d80, 0x0dff]],
+ ['Sinhala MN', 'Nirmala UI', 'Iskoola Pota', 'Noto Sans Sinhala'],
+ ],
+ [
+ [[0x0e80, 0x0eff]],
+ ['Lao MN', 'Lao Sangam MN', 'Leelawadee UI', 'Noto Sans Lao'],
+ ],
+ [
+ [[0x1000, 0x109f]],
+ ['Myanmar MN', 'Myanmar Sangam MN', 'Myanmar Text', 'Noto Sans Myanmar'],
+ ],
+ [
+ [[0x1780, 0x17ff]],
+ ['Khmer MN', 'Khmer Sangam MN', 'Leelawadee UI', 'Noto Sans Khmer'],
+ ],
+ [
+ [[0x0530, 0x058f]],
+ ['Mshtakan', 'Sylfaen', 'Noto Sans Armenian', 'Arial Unicode MS'],
+ ],
+ [[[0x10a0, 0x10ff]], ['Noto Sans Georgian', 'Sylfaen', 'Arial Unicode MS']],
+ [[[0x1200, 0x137f]], ['Kefa', 'Nyala', 'Noto Sans Ethiopic']],
+ [
+ [[0x0f00, 0x0fff]],
+ ['Kailasa', 'Microsoft Himalaya', 'Noto Sans Tibetan'],
+ ],
+ [[[0x13a0, 0x13ff]], ['Plantagenet Cherokee', 'Noto Sans Cherokee']],
+ [[[0x0700, 0x074f]], ['Noto Sans Syriac', 'Estrangelo Edessa']],
+ [[[0x0780, 0x07bf]], ['Noto Sans Thaana', 'MV Boli']],
+ ];
+ const out = [];
+ for (const cp of cps) {
+ for (const [ranges, fams] of TABLE) {
+ if (ranges.some(([a, b]) => cp >= a && cp <= b))
+ for (const f of fams) if (!out.includes(f)) out.push(f);
+ }
+ }
+ return out;
+}
+
+const PAGE_CACHE_MAX = 6;
+
+export class PdfEngine {
+ static localFonts = new Map();
+
+ constructor(module) {
+ this.M = module;
+ this.doc = 0;
+ this.page = 0;
+ this.session = 0;
+ this.pageIndex = 0;
+ this.pageCount = 0;
+ this.pageWidth = 0;
+ this.pageHeight = 0;
+ this._docBuf = 0;
+ this._providerPtr = 0;
+ }
+
+ _makeProvider() {
+ const M = this.M;
+ const norm = (s) =>
+ s
+ .toLowerCase()
+ .replace(/[^a-z0-9]+/g, '')
+ .replace(/(psmt|mt|ps)$/, '');
+ const normLookup = (fam, styleKey, want) => {
+ const target = norm(fam);
+ if (!target) return null;
+ let anyStyle = null;
+ for (const [k, b] of PdfEngine.localFonts) {
+ if (!b || !b.length) continue;
+ const bar = k.indexOf('|');
+ const kf = bar < 0 ? k : k.slice(0, bar);
+ if (norm(kf) !== target) continue;
+ if (want.length && !sfntCovers(b, want)) continue;
+ const ks = bar < 0 ? '' : k.slice(bar + 1);
+ if (ks === styleKey) return b;
+ if (!anyStyle) anyStyle = b;
+ }
+ return anyStyle;
+ };
+ return M.addFunction(
+ (ctx, familyPtr, bold, italic, cps, n, outData, outSize) => {
+ let fam = M.UTF8ToString(familyPtr);
+ fam = fam.replace(/ (serif|mono)$/, '');
+ const want = [];
+ for (let i = 0; i < n; i++) want.push(M.HEAPU32[(cps >> 2) + i]);
+ const styleKey = (bold ? 1 : 0) + '' + (italic ? 1 : 0);
+ const sk = '|' + styleKey;
+ const names = [fam, ...scriptFallbackFamilies(want)];
+ let bytes = null;
+ for (const nm of names) {
+ for (const k of [nm + sk, nm]) {
+ const b = PdfEngine.localFonts.get(k);
+ if (b && b.length && (!want.length || sfntCovers(b, want))) {
+ bytes = b;
+ break;
+ }
+ }
+ if (bytes) break;
+ }
+ if (!bytes) bytes = normLookup(fam, styleKey, want);
+ if (!bytes || !bytes.length) return 0;
+ const buf = M._ec_buffer_alloc(bytes.length);
+ if (!buf) return 0;
+ M.HEAPU8.set(bytes, buf);
+ M.HEAPU32[outData >> 2] = buf;
+ M.HEAPU32[outSize >> 2] = bytes.length;
+ return 1;
+ },
+ 'iiiiiiiii'
+ );
+ }
+
+ static async create() {
+ const M = await createEditCore(
+ devBust
+ ? {
+ locateFile: (f, prefix) => `${prefix}${f}?v=${devBust}`,
+ }
+ : undefined
+ );
+ const cfg = M._malloc(48);
+ M.HEAPU8.fill(0, cfg, cfg + 48);
+ M.HEAPU32[cfg >> 2] = 2;
+ M._FPDF_InitLibraryWithConfig(cfg);
+ M._free(cfg);
+ return new PdfEngine(M);
+ }
+
+ static disambiguateSubsetFonts(bytes) {
+ const probe = probePdfBytes(bytes);
+ if (!probe.subsetFont || !probe.form) return bytes;
+ const s = new TextDecoder('latin1').decode(bytes);
+ const fonts = [];
+ for (const m of s.matchAll(/(\d+)\s+0\s+obj\b/g)) {
+ const end = s.indexOf('endobj', m.index);
+ if (end < 0) continue;
+ const body = s.slice(m.index, end);
+ if (!/\/Type\s*\/Font\b/.test(body)) continue;
+ const bf = body.match(/\/BaseFont\s*\/([A-Z]{6})\+([^\s/\[\]<>()]+)/);
+ if (!bf) continue;
+ const fd = body.match(/\/FontDescriptor\s+(\d+)\s+0\s+R/);
+ fonts.push({
+ obj: Number(m[1]),
+ tagAt: m.index + bf.index + bf[0].indexOf(bf[1]),
+ tag: bf[1],
+ name: bf[2],
+ desc: fd ? Number(fd[1]) : 0,
+ });
+ }
+ if (!fonts.length) return bytes;
+ const containerFonts = new Set();
+ for (const m of s.matchAll(/(\d+)\s+0\s+obj\b/g)) {
+ const end = s.indexOf('endobj', m.index);
+ if (end < 0) continue;
+ const body = s.slice(m.index, end);
+ if (!/\/Subtype\s*\/Form\b/.test(body)) continue;
+ const fi = body.indexOf('/Font');
+ if (fi < 0) continue;
+ for (const r of body.slice(fi, fi + 800).matchAll(/(\d+)\s+0\s+R/g))
+ containerFonts.add(Number(r[1]));
+ }
+ if (!containerFonts.size) return bytes;
+ const groups = new Map();
+ for (const f of fonts) {
+ const k = f.tag + '+' + f.name;
+ if (!groups.has(k)) groups.set(k, []);
+ groups.get(k).push(f);
+ }
+ const taken = new Set(fonts.map((f) => f.tag));
+ const tagFor = (n) => {
+ let t = '';
+ for (let i = 0; i < 6; i++) {
+ t = String.fromCharCode(65 + (n % 26)) + t;
+ n = Math.floor(n / 26);
+ }
+ return t;
+ };
+ let seq = 0;
+ const nextTag = () => {
+ let t;
+ do {
+ t = tagFor(seq++);
+ } while (taken.has(t));
+ taken.add(t);
+ return t;
+ };
+ const esc = (x) => x.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
+ const faces = new Set(fonts.map((f) => f.name));
+ const edits = [];
+ for (const [, list] of groups) {
+ if (list.length < 2) continue;
+ if (
+ !list.some((f) => containerFonts.has(f.obj)) ||
+ !list.some((f) => !containerFonts.has(f.obj))
+ )
+ continue;
+ for (const f of list) {
+ if (!containerFonts.has(f.obj) || !f.name.length) continue;
+ let face = null;
+ for (let c = 0; c < 26 && !face; c++) {
+ const cand = f.name.slice(0, -1) + String.fromCharCode(65 + c);
+ if (!faces.has(cand)) face = cand;
+ }
+ if (!face) continue;
+ faces.add(face);
+ const tag = nextTag(),
+ lastCh = face[face.length - 1];
+ edits.push([f.tagAt, tag], [f.tagAt + 7 + f.name.length - 1, lastCh]);
+ if (!f.desc) continue;
+ const dm = s.match(
+ new RegExp('(?:^|[^0-9])(' + f.desc + '\\s+0\\s+obj\\b)')
+ );
+ if (!dm) continue;
+ const dAt = dm.index + dm[0].indexOf(dm[1]);
+ const dEnd = s.indexOf('endobj', dAt);
+ const dBody = s.slice(dAt, dEnd < 0 ? undefined : dEnd);
+ const fn = dBody.match(
+ new RegExp('/FontName\\s*/(' + f.tag + ')\\+' + esc(f.name))
+ );
+ if (!fn) continue;
+ const at = dAt + fn.index + fn[0].indexOf(fn[1]);
+ edits.push([at, tag], [at + 7 + f.name.length - 1, lastCh]);
+ }
+ }
+ if (!edits.length) return bytes;
+ const out = new Uint8Array(bytes);
+ for (const [at, text] of edits) {
+ for (let i = 0; i < text.length; i++) out[at + i] = text.charCodeAt(i);
+ }
+ return out;
+ }
+
+ open(bytes, password, _reentrant) {
+ bytes = PdfEngine.disambiguateSubsetFonts(bytes);
+ this._originalBytes = bytes;
+ this.close();
+ const M = this.M;
+ const buf = M._malloc(bytes.length);
+ M.HEAPU8.set(bytes, buf);
+ let pw = 0;
+ if (password) {
+ const n = M.lengthBytesUTF8(password) + 1;
+ pw = M._malloc(n);
+ M.stringToUTF8(password, pw, n);
+ }
+ const doc = M._FPDF_LoadMemDocument64(buf, bytes.length, pw);
+ if (pw) M._free(pw);
+ if (!doc) {
+ const err = M._FPDF_GetLastError ? M._FPDF_GetLastError() : -1;
+ M._free(buf);
+ this.lastOpenError = err;
+ const e = new Error(
+ err === 4
+ ? 'This PDF is password-protected.'
+ : 'This file could not be opened as a PDF.'
+ );
+ e.passwordRequired = err === 4;
+ throw e;
+ }
+ this._docBuf = buf;
+ this.doc = doc;
+ if (!this._providerPtr) this._providerPtr = this._makeProvider();
+ this.session = M._ec_session_create(doc, this._providerPtr, 0);
+ if (M._ec_set_flatten_forms) {
+ const usesPattern = probePdfBytes(bytes).pattern;
+ this._usesPattern = usesPattern;
+ M._ec_set_flatten_forms(this.session, usesPattern ? 0 : 1);
+ }
+ this.pageCount = M._FPDF_GetPageCount(doc);
+ this._normalized = new Set();
+ this._reencoded = new Set();
+ this._regenHostile = new Set();
+ this._editedPages = new Set();
+ this._t3seg = null;
+ this._t3state = {};
+ this._t3anchors = {};
+ this._fragilePages = null;
+ this.loadPage(0);
+
+ if (!_reentrant) {
+ const info = this.documentInfo();
+ this.security = info;
+ this._encrypted = !!info.encrypted && !info.signatures;
+ }
+ }
+
+ reopen(bytes, pageIndex) {
+ const keep = this.pageIndex;
+ const orig = this._originalBytes;
+ const edited = this._editedPages;
+ const t3seg = this._t3seg;
+ const fragile = this._fragilePages;
+ this._splicePlans = null;
+ this._spliceOk = false;
+ this.open(bytes);
+ if (orig) this._originalBytes = orig;
+ if (edited) this._editedPages = edited;
+ if (fragile) this.setFragilePages([...fragile]);
+ if (t3seg) this._t3seg = t3seg;
+ this.loadPage(Math.min(pageIndex ?? keep, this.pageCount - 1));
+ }
+
+ setType3Seg(seg) {
+ if (seg && Object.keys(seg).length) this._t3seg = seg;
+ }
+
+ _t3readState() {
+ const ptr = this.M._ec_page_text_state(this.session, this.page);
+ if (!ptr) return null;
+ let st = null;
+ try {
+ st = JSON.parse(this.M.UTF8ToString(ptr));
+ } catch {}
+ this.M._ec_string_free(ptr);
+ return st;
+ }
+
+ _t3anchor(st) {
+ const seg = this._t3seg?.[this.pageIndex];
+ if (!seg || !st) return;
+ const anchors = ((this._t3anchors ||= {})[this.pageIndex] ||= {});
+ for (const o of st) {
+ if (!o.t3 || !o.glyphs || !o.glyphs.length) continue;
+ if (anchors[o.font] != null) continue;
+ const oCodes = o.glyphs.map((g) => g[0]);
+ const hit = seg.tjs.find(
+ (tj) =>
+ Math.abs(tj.e - o.m[4]) < 0.05 &&
+ Math.abs(tj.f - o.m[5]) < 0.05 &&
+ tj.codes.length >= oCodes.length &&
+ oCodes.every((c, k) => c === tj.codes[k])
+ );
+ if (hit) anchors[o.font] = hit.font;
+ }
+ }
+
+ close() {
+ const M = this.M;
+ if (this._pageCache) {
+ for (const [, handle] of this._pageCache)
+ if (handle && handle !== this.page) M._FPDF_ClosePage(handle);
+ this._pageCache.clear();
+ }
+ if (this.page) M._FPDF_ClosePage(this.page);
+ if (this.session) M._ec_session_destroy(this.session);
+ if (this.doc) M._FPDF_CloseDocument(this.doc);
+ if (this._docBuf) M._free(this._docBuf);
+ this.page = this.session = this.doc = this._docBuf = 0;
+ }
+
+ loadPage(index) {
+ const M = this.M;
+ this.renderParagraphLiveEnd();
+ if (this.page && this._pageDirty) this.generateContent();
+ const cache = (this._pageCache ||= new Map());
+ if (this.page) cache.set(this.pageIndex, this.page);
+ let handle = cache.get(index);
+ if (handle) cache.delete(index);
+ else handle = M._FPDF_LoadPage(this.doc, index);
+ cache.set(index, handle);
+ this.page = handle;
+ while (cache.size > PAGE_CACHE_MAX) {
+ let victim = null;
+ for (const key of cache.keys()) {
+ if (key === index || this.pinnedPages?.has(key)) continue;
+ victim = key;
+ break;
+ }
+ if (victim === null) break;
+ const dead = cache.get(victim);
+ cache.delete(victim);
+ if (dead) {
+ this.M._ec_history_drop_page?.(this.session, dead);
+ M._FPDF_ClosePage(dead);
+ }
+ this.onPageEvicted?.(victim);
+ }
+ this.pageIndex = index;
+ this._pageDirty = false;
+ this.pageWidth = M._FPDF_GetPageWidthF(this.page);
+ this.pageHeight = M._FPDF_GetPageHeightF(this.page);
+ if (this._fragilePages?.has(index)) {
+ M._ec_mark_fonts_fragile(this.session, this.page);
+ }
+ this._readPageTransform();
+ }
+
+ setFragilePages(pages) {
+ this._fragilePages = new Set(pages || []);
+ if (this.page && this._fragilePages.has(this.pageIndex)) {
+ this.M._ec_mark_fonts_fragile(this.session, this.page);
+ }
+ }
+
+ pageWasNormalized() {
+ return !!this._reencoded?.has(this.pageIndex);
+ }
+
+ normalizeFontsForEdit() {
+ if (!this.session || this._normalized?.has(this.pageIndex)) return;
+ (this._normalized ||= new Set()).add(this.pageIndex);
+ const lossy = this.M._ec_page_regen_is_lossy(
+ this.session,
+ this.page,
+ this.pageIndex
+ );
+ if (lossy === 2) {
+ (this._regenHostile ||= new Set()).add(this.pageIndex);
+ return 0;
+ }
+ if (lossy === 3) {
+ return 0;
+ }
+ if (lossy) {
+ const rebuilt = this.M._ec_reencode_page_fonts(this.session, this.page);
+ this._pageDirty = true;
+ if (rebuilt > 0) (this._reencoded ||= new Set()).add(this.pageIndex);
+ return rebuilt;
+ }
+ return 0;
+ }
+
+ pageRegenHostile() {
+ return !!this._regenHostile?.has(this.pageIndex);
+ }
+
+ documentInfo() {
+ const M = this.M;
+ const ptr = M._ec_document_info(this.doc);
+ if (!ptr) return { pages: 0, signatures: 0 };
+ const j = M.UTF8ToString(ptr);
+ M._ec_string_free(ptr);
+ return JSON.parse(j);
+ }
+
+ metaText(tag) {
+ const M = this.M;
+ const kp = M._malloc(tag.length + 1);
+ for (let i = 0; i < tag.length; i++) M.HEAPU8[kp + i] = tag.charCodeAt(i);
+ M.HEAPU8[kp + tag.length] = 0;
+ const n = M._FPDF_GetMetaText(this.doc, kp, 0, 0);
+ let out = '';
+ if (n > 2) {
+ const bp = M._malloc(n);
+ M._FPDF_GetMetaText(this.doc, kp, bp, n);
+ const chars = [];
+ for (let i = 0; i + 1 < n - 1; i += 2)
+ chars.push(M.HEAPU8[bp + i] | (M.HEAPU8[bp + i + 1] << 8));
+ out = String.fromCharCode(...chars);
+ M._free(bp);
+ }
+ M._free(kp);
+ return out;
+ }
+
+ static sniffPdfA(bytes) {
+ const probe = 'pdfaid:part';
+ const limit = Math.min(bytes.length, 4 << 20);
+ outer: for (let i = 0; i + probe.length < limit; i++) {
+ if (bytes[i] !== 112) continue;
+ for (let j = 0; j < probe.length; j++) {
+ if (bytes[i + j] !== probe.charCodeAt(j)) continue outer;
+ }
+ return true;
+ }
+ return false;
+ }
+
+ buildModel() {
+ const M = this.M;
+ if (this._pageStale) {
+ this.renderParagraphLiveEnd();
+ if (this._pageDirty) this.generateContent();
+ M._FPDF_ClosePage(this.page);
+ this.page = M._FPDF_LoadPage(this.doc, this.pageIndex);
+ this._pageCache?.set(this.pageIndex, this.page);
+ this.onPageEvicted?.(this.pageIndex);
+ this._pageStale = false;
+ this._pageDirty = false;
+ }
+ if (this._t3seg && this._t3seg[this.pageIndex]) {
+ const st = this._t3readState();
+ if (st) this._t3anchor(st);
+ }
+ const ptr = M._ec_build_page_model(this.session, this.page);
+ if (!ptr) return [];
+ const json = M.UTF8ToString(ptr);
+ M._ec_string_free(ptr);
+ this._readPageTransform();
+ return JSON.parse(json).paragraphs;
+ }
+
+ _readPageTransform() {
+ const M = this.M;
+ if (!this.session || !this.page) return;
+ const cp = M._malloc(24);
+ M._ec_page_transform(this.session, this.page, cp);
+ this.pageT = Array.from(
+ { length: 6 },
+ (_, i) => M.HEAPF32[(cp + i * 4) >> 2]
+ );
+ M._free(cp);
+ this.cropX = -this.pageT[4];
+ this.cropY = -this.pageT[5];
+ }
+
+ toModel(x, y) {
+ const t = this.pageT || [1, 0, 0, 1, 0, 0];
+ return { x: t[0] * x + t[2] * y + t[4], y: t[1] * x + t[3] * y + t[5] };
+ }
+ toUser(x, y) {
+ const t = this.pageT || [1, 0, 0, 1, 0, 0];
+ const det = t[0] * t[3] - t[1] * t[2] || 1;
+ const dx = x - t[4],
+ dy = y - t[5];
+ return {
+ x: (t[3] * dx - t[2] * dy) / det,
+ y: (-t[1] * dx + t[0] * dy) / det,
+ };
+ }
+
+ _allocStr(s) {
+ const M = this.M;
+ const n = M.lengthBytesUTF8(s || '') + 1;
+ const p = M._malloc(n);
+ M.stringToUTF8(s || '', p, n);
+ return p;
+ }
+
+ _writeRuns(runs) {
+ const M = this.M;
+ const arr = M._malloc(RUN_SIZE * runs.length);
+ const strs = [];
+ runs.forEach((r, i) => {
+ const b = arr + i * RUN_SIZE;
+ const tp = this._allocStr(r.text);
+ const fp = this._allocStr(r.family || '');
+ strs.push(tp, fp);
+ M.HEAPU32[(b + 0) >> 2] = tp;
+ M.HEAPU32[(b + 4) >> 2] = fp;
+ M.HEAP32[(b + 8) >> 2] = r.bold === 2 ? 2 : r.bold ? 1 : 0;
+ M.HEAP32[(b + 12) >> 2] = r.italic === 2 ? 2 : r.italic ? 1 : 0;
+ M.HEAPF32[(b + 16) >> 2] = r.size;
+ M.HEAPU32[(b + 20) >> 2] = r.rgba >>> 0;
+ M.HEAP32[(b + 24) >> 2] = r.underline ? 1 : 0;
+ M.HEAP32[(b + 28) >> 2] = r.strike ? 1 : 0;
+ M.HEAP32[(b + 32) >> 2] = r.script | 0;
+ M.HEAP32[(b + 36) >> 2] = r.sourceIndex == null ? -1 : r.sourceIndex;
+ M.HEAP32[(b + 40) >> 2] = r.renderMode | 0;
+ M.HEAPU32[(b + 44) >> 2] = (r.strokeRgba || 0) >>> 0;
+ M.HEAPF32[(b + 48) >> 2] = r.strokeWidth || 1;
+ M.HEAPF32[(b + 52) >> 2] = r.hScale || 1;
+ M.HEAPF32[(b + 56) >> 2] = r.rise || 0;
+ });
+ return { arr, strs, count: runs.length };
+ }
+
+ _writeFmt(fmt) {
+ const M = this.M;
+ const p = M._malloc(FMT_SIZE);
+ M.HEAP32[(p + 0) >> 2] = fmt.align | 0;
+ M.HEAPF32[(p + 4) >> 2] = fmt.lineSpacing;
+ M.HEAPF32[(p + 8) >> 2] = fmt.charSpacing || 0;
+ M.HEAPF32[(p + 12) >> 2] = fmt.paraSpacing || 0;
+ M.HEAPF32[(p + 16) >> 2] = fmt.wordSpacing || 0;
+ M.HEAPF32[(p + 20) >> 2] = fmt.firstIndent || 0;
+ M.HEAPF32[(p + 24) >> 2] = fmt.hangIndent || 0;
+ M.HEAP32[(p + 28) >> 2] = fmt.dir | 0;
+ M.HEAP32[(p + 32) >> 2] = fmt.listLevel | 0;
+ return p;
+ }
+
+ _free(alloc) {
+ for (const s of alloc.strs) this.M._free(s);
+ this.M._free(alloc.arr);
+ }
+
+ _decode(ptr) {
+ if (!ptr) return null;
+ const j = this.M.UTF8ToString(ptr);
+ this.M._ec_string_free(ptr);
+ return JSON.parse(j);
+ }
+
+ previewParagraph(id, runs, fmt) {
+ const a = this._writeRuns(runs);
+ const f = this._writeFmt(fmt);
+ const ptr = this.M._ec_preview_paragraph(
+ this.session,
+ this.page,
+ id,
+ a.arr,
+ a.count,
+ f
+ );
+ this._free(a);
+ this.M._free(f);
+ if (!ptr) return null;
+ const json = this.M.UTF8ToString(ptr);
+ this.M._ec_string_free(ptr);
+ try {
+ return JSON.parse(json);
+ } catch {
+ return null;
+ }
+ }
+
+ commitParagraph(id, runs, fmt) {
+ this._pageDirty = true;
+ const a = this._writeRuns(runs);
+ const f = this._writeFmt(fmt);
+ const out = this._decode(
+ this.M._ec_commit_paragraph(
+ this.session,
+ this.page,
+ id,
+ a.arr,
+ a.count,
+ f
+ )
+ );
+ this._free(a);
+ this.M._free(f);
+ return out;
+ }
+
+ renderParagraphLive(id, runs, fmt, scale, mx, my, mw, mh) {
+ const M = this.M;
+ const a = this._writeRuns(runs);
+ const f = this._writeFmt(fmt);
+ const wp = M._malloc(8);
+ M.HEAP32[wp >> 2] = 0;
+ M.HEAP32[(wp + 4) >> 2] = 0;
+ const ptr = M._ec_render_paragraph_live(
+ this.session,
+ this.page,
+ id,
+ a.arr,
+ a.count,
+ f,
+ scale,
+ mx,
+ my,
+ mw,
+ mh,
+ wp,
+ wp + 4
+ );
+ const w = M.HEAP32[wp >> 2],
+ h = M.HEAP32[(wp + 4) >> 2];
+ M._free(wp);
+ this._free(a);
+ M._free(f);
+ if (!ptr || !w || !h) return null;
+ const data = new Uint8ClampedArray(M.HEAPU8.subarray(ptr, ptr + w * h * 4));
+ M._ec_string_free(ptr);
+ return { width: w, height: h, data };
+ }
+
+ renderPageRegion(fullW, fullH, px, py, pw, ph) {
+ const M = this.M;
+ const FPDFBitmap_BGRA = 4;
+ const bmp = M._FPDFBitmap_CreateEx(pw, ph, FPDFBitmap_BGRA, 0, 0);
+ if (!bmp) return null;
+ M._FPDFBitmap_FillRect(bmp, 0, 0, pw, ph, 0xffffffff);
+ const FPDF_ANNOT = 0x01,
+ FPDF_LCD_TEXT = 0x02,
+ FPDF_REVERSE_BYTE_ORDER = 0x10;
+ const FLAGS = FPDF_ANNOT | FPDF_LCD_TEXT | FPDF_REVERSE_BYTE_ORDER;
+ M._FPDF_RenderPageBitmap(bmp, this.page, -px, -py, fullW, fullH, 0, FLAGS);
+ M._ec_form_draw(
+ this.session,
+ this.page,
+ bmp,
+ -px,
+ -py,
+ fullW,
+ fullH,
+ 0,
+ FLAGS
+ );
+ const buf = M._FPDFBitmap_GetBuffer(bmp);
+ const stride = M._FPDFBitmap_GetStride(bmp);
+ const out = new Uint8ClampedArray(pw * ph * 4);
+ if (stride === pw * 4) {
+ out.set(M.HEAPU8.subarray(buf, buf + pw * ph * 4));
+ } else {
+ for (let y = 0; y < ph; y++) {
+ out.set(
+ M.HEAPU8.subarray(buf + y * stride, buf + y * stride + pw * 4),
+ y * pw * 4
+ );
+ }
+ }
+ M._FPDFBitmap_Destroy(bmp);
+ return { width: pw, height: ph, data: out };
+ }
+
+ addParagraph(x, yTop, width, runs, fmt) {
+ this._pageDirty = true;
+ const a = this._writeRuns(runs);
+ const f = this._writeFmt(fmt);
+ const out = this._decode(
+ this.M._ec_add_paragraph(
+ this.session,
+ this.page,
+ x,
+ yTop,
+ width,
+ a.arr,
+ a.count,
+ f
+ )
+ );
+ this._free(a);
+ this.M._free(f);
+ return out;
+ }
+
+ synthRunFont(paraId, runIndex) {
+ const M = this.M;
+ const sp = M._malloc(4);
+ M.HEAPU32[sp >> 2] = 0;
+ const ptr = M._ec_synth_run_font(
+ this.session,
+ this.page,
+ paraId,
+ runIndex,
+ sp
+ );
+ const size = M.HEAPU32[sp >> 2];
+ M._free(sp);
+ if (!ptr || !size) return null;
+ if (size === 1) {
+ const flag = M.HEAPU8[ptr];
+ M._ec_string_free(ptr);
+ return flag === 0xdd ? 'dishonest' : null;
+ }
+ const bytes = M.HEAPU8.slice(ptr, ptr + size);
+ M._ec_string_free(ptr);
+ return bytes;
+ }
+
+ duplicateParagraph(id, dx, dy) {
+ this._pageDirty = true;
+ return this._decode(
+ this.M._ec_duplicate_paragraph(this.session, this.page, id, dx, dy)
+ );
+ }
+
+ cloneMarker(srcId, dstId) {
+ this._pageDirty = true;
+ return this.M._ec_clone_marker(this.session, this.page, srcId, dstId) !== 0;
+ }
+
+ moveParagraph(id, dx, dy) {
+ this._pageDirty = true;
+ return this.M._ec_move_paragraph(this.session, this.page, id, dx, dy) !== 0;
+ }
+
+ resizeParagraph(id, width) {
+ this._pageDirty = true;
+ return this._decode(
+ this.M._ec_resize_paragraph(this.session, this.page, id, width)
+ );
+ }
+
+ historyBeginStep(label) {
+ const M = this.M;
+ const lbl = this._allocStr(label || 'edit');
+ M._ec_history_begin(this.session, this.page, lbl);
+ M._free(lbl);
+ }
+ historyEndStep() {
+ const before = this.M._ec_history_depth(this.session, 0);
+ this.M._ec_history_end(this.session, this.page);
+ if (this._spliceOk === false) return;
+ if (this.M._ec_history_depth(this.session, 0) <= before) return;
+ const plan = this.lastSplicePlan();
+ if (plan) (this._splicePlans ||= []).push({ page: this.pageIndex, plan });
+ else this._spliceOk = false;
+ }
+ lastSplicePlan() {
+ const ptr = this.M._ec_last_splice_plan(this.session, this.page);
+ if (!ptr) return null;
+ const json = this.M.UTF8ToString(ptr);
+ this.M._ec_string_free(ptr);
+ try {
+ return JSON.parse(json);
+ } catch {
+ return null;
+ }
+ }
+ editStep(label, fn) {
+ this.historyBeginStep(label);
+ try {
+ return fn();
+ } finally {
+ this.historyEndStep();
+ }
+ }
+ historyNoteMatrix(handle) {
+ this.M._ec_history_note_matrix(this.session, this.page, handle);
+ }
+ historyNoteZOrder(handle) {
+ this.M._ec_history_note_zorder(this.session, this.page, handle);
+ }
+ historyNoteInsert(handle) {
+ this.M._ec_history_note_insert(this.session, this.page, handle);
+ }
+ historyRemoveObject(handle) {
+ this._pageDirty = true;
+ return (
+ this.M._ec_history_remove_object(this.session, this.page, handle) !== 0
+ );
+ }
+ historyUndo() {
+ this._pageDirty = true;
+ const ok = this.M._ec_history_undo(this.session, this.page) !== 0;
+ if (ok && this._splicePlans?.length) {
+ const last = this._splicePlans[this._splicePlans.length - 1];
+ if (last.page === this.pageIndex) this._splicePlans.pop();
+ else this._spliceOk = false;
+ }
+ return ok;
+ }
+ historyRedo() {
+ this._pageDirty = true;
+ const ok = this.M._ec_history_redo(this.session, this.page) !== 0;
+ if (ok && this._spliceOk !== false) {
+ const plan = this.lastSplicePlan();
+ if (plan) (this._splicePlans ||= []).push({ page: this.pageIndex, plan });
+ else this._spliceOk = false;
+ }
+ return ok;
+ }
+ historyDepth() {
+ return {
+ undo: this.M._ec_history_depth(this.session, 0),
+ redo: this.M._ec_history_depth(this.session, 1),
+ };
+ }
+ historyClear() {
+ this.M._ec_history_clear(this.session);
+ }
+
+ spellLoad(bytes) {
+ const M = this.M;
+ const buf = M._malloc(bytes.length);
+ M.HEAPU8.set(bytes, buf);
+ const n = M._ec_spell_load(this.session, buf, bytes.length);
+ M._free(buf);
+ return n;
+ }
+ spellCheckPage() {
+ const M = this.M;
+ const ptr = M._ec_spell_check_page(this.session, this.page);
+ if (!ptr) return null;
+ const json = M.UTF8ToString(ptr);
+ M._ec_string_free(ptr);
+ try {
+ return JSON.parse(json);
+ } catch {
+ return null;
+ }
+ }
+
+ selectText(x0, y0, x1, y1, mode) {
+ const M = this.M;
+ const ptr = M._ec_select_text(
+ this.session,
+ this.page,
+ x0,
+ y0,
+ x1,
+ y1,
+ mode | 0
+ );
+ if (!ptr) return null;
+ const json = M.UTF8ToString(ptr);
+ M._ec_string_free(ptr);
+ try {
+ return JSON.parse(json);
+ } catch {
+ return null;
+ }
+ }
+
+ pageTextJson() {
+ const M = this.M;
+ const ptr = M._ec_page_text_json(this.session, this.page);
+ if (!ptr) return null;
+ const json = M.UTF8ToString(ptr);
+ M._ec_string_free(ptr);
+ try {
+ return JSON.parse(json);
+ } catch {
+ return null;
+ }
+ }
+
+ buildModelInRegion(x, y, w, h) {
+ const M = this.M;
+ const ptr = M._ec_build_page_model_region(
+ this.session,
+ this.page,
+ x,
+ y,
+ w,
+ h
+ );
+ if (!ptr) return [];
+ const json = M.UTF8ToString(ptr);
+ M._ec_string_free(ptr);
+ try {
+ return JSON.parse(json).paragraphs || [];
+ } catch {
+ return [];
+ }
+ }
+
+ deleteParagraph(id) {
+ this._pageDirty = true;
+ return this.M._ec_delete_paragraph(this.session, this.page, id) !== 0;
+ }
+
+ runFontData(paraId, runIndex) {
+ const M = this.M;
+ const needed = M._ec_get_run_font_data(
+ this.session,
+ this.page,
+ paraId,
+ runIndex,
+ 0,
+ 0
+ );
+ if (!needed) return null;
+ const buf = M._malloc(needed);
+ const written = M._ec_get_run_font_data(
+ this.session,
+ this.page,
+ paraId,
+ runIndex,
+ buf,
+ needed
+ );
+ const out =
+ written > 0
+ ? M.HEAPU8.slice(buf, buf + Math.min(needed, written || needed))
+ : null;
+ M._free(buf);
+ return out && out.length ? out : null;
+ }
+
+ paragraphObjects(id) {
+ const M = this.M;
+ const total = M._ec_get_paragraph_objects(
+ this.session,
+ this.page,
+ id,
+ 0,
+ 0
+ );
+ if (total <= 0) return [];
+ const out = M._malloc(total * 4);
+ M._ec_get_paragraph_objects(this.session, this.page, id, out, total);
+ const handles = [];
+ for (let i = 0; i < total; i++) handles.push(M.HEAPU32[(out >> 2) + i]);
+ M._free(out);
+ return handles;
+ }
+
+ objectCount() {
+ return this.M._FPDFPage_CountObjects(this.page);
+ }
+
+ objectAt(index) {
+ const M = this.M;
+ const h = M._FPDFPage_GetObject(this.page, index);
+ if (!h) return null;
+ return { handle: h, index, type: M._FPDFPageObj_GetType(h) };
+ }
+
+ objectBounds(handle) {
+ const b = this._rawBounds(handle);
+ if (!b) return null;
+ const p0 = this.toModel(b.x, b.y);
+ const p1 = this.toModel(b.x + b.w, b.y + b.h);
+ return {
+ ...b,
+ x: Math.min(p0.x, p1.x),
+ y: Math.min(p0.y, p1.y),
+ w: Math.abs(p1.x - p0.x),
+ h: Math.abs(p1.y - p0.y),
+ };
+ }
+
+ _rawBounds(handle) {
+ const M = this.M;
+ const p = (this._boundsScratch ||= M._malloc(16));
+ const ok = M._FPDFPageObj_GetBounds(handle, p, p + 4, p + 8, p + 12);
+ if (!ok) return null;
+ const l = M.HEAPF32[p >> 2],
+ b = M.HEAPF32[(p + 4) >> 2];
+ const r = M.HEAPF32[(p + 8) >> 2],
+ t = M.HEAPF32[(p + 12) >> 2];
+ return { x: l, y: b, w: r - l, h: t - b };
+ }
+
+ deltaToUser(dx, dy) {
+ const t = this.pageT || [1, 0, 0, 1, 0, 0];
+ const det = t[0] * t[3] - t[1] * t[2] || 1;
+ return {
+ x: (t[3] * dx - t[2] * dy) / det,
+ y: (-t[1] * dx + t[0] * dy) / det,
+ };
+ }
+
+ translateObject(handle, dx, dy) {
+ this._pageDirty = true;
+ ({ x: dx, y: dy } = this.deltaToUser(dx, dy));
+ const M = this.M;
+ const m = M._malloc(24);
+ M.HEAPF32[(m + 0) >> 2] = 1;
+ M.HEAPF32[(m + 4) >> 2] = 0;
+ M.HEAPF32[(m + 8) >> 2] = 0;
+ M.HEAPF32[(m + 12) >> 2] = 1;
+ M.HEAPF32[(m + 16) >> 2] = dx;
+ M.HEAPF32[(m + 20) >> 2] = dy;
+ M._FPDFPageObj_TransformClipPath(handle, 1, 0, 0, 1, dx, dy);
+ M._FPDFPageObj_TransformF(handle, m);
+ M._free(m);
+ }
+
+ transformObjectAboutCenter(handle, a, b, c, d) {
+ this._pageDirty = true;
+ const M = this.M;
+ const bounds = this._rawBounds(handle);
+ if (!bounds) return;
+ const cx = bounds.x + bounds.w / 2,
+ cy = bounds.y + bounds.h / 2;
+ const e = cx - a * cx - c * cy;
+ const f = cy - b * cx - d * cy;
+ const m = M._malloc(24);
+ M.HEAPF32[(m + 0) >> 2] = a;
+ M.HEAPF32[(m + 4) >> 2] = b;
+ M.HEAPF32[(m + 8) >> 2] = c;
+ M.HEAPF32[(m + 12) >> 2] = d;
+ M.HEAPF32[(m + 16) >> 2] = e;
+ M.HEAPF32[(m + 20) >> 2] = f;
+ M._FPDFPageObj_TransformClipPath(handle, a, b, c, d, e, f);
+ M._FPDFPageObj_TransformF(handle, m);
+ M._free(m);
+ }
+
+ rotateObject(handle, deg) {
+ this._pageDirty = true;
+ const r = (deg * Math.PI) / 180;
+ this.transformObjectAboutCenter(
+ handle,
+ Math.cos(r),
+ Math.sin(r),
+ -Math.sin(r),
+ Math.cos(r)
+ );
+ }
+
+ rotateObjectsAbout(handles, deg, cx, cy) {
+ ({ x: cx, y: cy } = this.toUser(cx, cy));
+ this._pageDirty = true;
+ const M = this.M;
+ const r = (deg * Math.PI) / 180,
+ c = Math.cos(r),
+ s = Math.sin(r);
+ const m = M._malloc(24);
+ M.HEAPF32[(m + 0) >> 2] = c;
+ M.HEAPF32[(m + 4) >> 2] = s;
+ M.HEAPF32[(m + 8) >> 2] = -s;
+ M.HEAPF32[(m + 12) >> 2] = c;
+ M.HEAPF32[(m + 16) >> 2] = cx - c * cx + s * cy;
+ M.HEAPF32[(m + 20) >> 2] = cy - s * cx - c * cy;
+ for (const h of handles) {
+ M._FPDFPageObj_TransformClipPath(
+ h,
+ c,
+ s,
+ -s,
+ c,
+ cx - c * cx + s * cy,
+ cy - s * cx - c * cy
+ );
+ M._FPDFPageObj_TransformF(h, m);
+ }
+ M._free(m);
+ }
+ flipObject(handle, horizontal) {
+ this._pageDirty = true;
+ this.transformObjectAboutCenter(
+ handle,
+ horizontal ? -1 : 1,
+ 0,
+ 0,
+ horizontal ? 1 : -1
+ );
+ }
+
+ replaceImage(handle, bgra, width, height) {
+ this._pageDirty = true;
+ const M = this.M;
+ const buf = M._malloc(bgra.length);
+ M.HEAPU8.set(bgra, buf);
+ const bmp = M._FPDFBitmap_CreateEx(width, height, 4, buf, width * 4);
+ let ok = false;
+ if (bmp) {
+ const pages = M._malloc(4);
+ M.HEAP32[pages >> 2] = this.page;
+ ok = !!M._FPDFImageObj_SetBitmap(pages, 1, handle, bmp);
+ M._free(pages);
+ M._FPDFBitmap_Destroy(bmp);
+ }
+ M._free(buf);
+ return ok;
+ }
+
+ renderImageObject(handle) {
+ const M = this.M;
+ const bmp = M._FPDFImageObj_GetRenderedBitmap(this.doc, this.page, handle);
+ if (!bmp) return null;
+ const w = M._FPDFBitmap_GetWidth(bmp),
+ h = M._FPDFBitmap_GetHeight(bmp);
+ const stride = M._FPDFBitmap_GetStride(bmp);
+ const buf = M._FPDFBitmap_GetBuffer(bmp);
+ const out = new Uint8ClampedArray(w * h * 4);
+ for (let y = 0; y < h; y++) {
+ for (let x = 0; x < w; x++) {
+ const si = buf + y * stride + x * 4,
+ di = (y * w + x) * 4;
+ out[di] = M.HEAPU8[si + 2];
+ out[di + 1] = M.HEAPU8[si + 1];
+ out[di + 2] = M.HEAPU8[si];
+ out[di + 3] = M.HEAPU8[si + 3];
+ }
+ }
+ M._FPDFBitmap_Destroy(bmp);
+ return { data: out, width: w, height: h };
+ }
+
+ untaggedMarks() {
+ const M = this.M;
+ const tree = M._FPDF_StructTree_GetForPage(this.page);
+ if (!tree) return [];
+ const registered = new Set();
+ const walk = (el) => {
+ const id = M._FPDF_StructElement_GetMarkedContentID(el);
+ if (id >= 0) registered.add(id);
+ const n = M._FPDF_StructElement_CountChildren(el);
+ for (let i = 0; i < n; i++) {
+ const ch = M._FPDF_StructElement_GetChildAtIndex(el, i);
+ if (ch) walk(ch);
+ }
+ };
+ const n = M._FPDF_StructTree_CountChildren(tree);
+ for (let i = 0; i < n; i++)
+ walk(M._FPDF_StructTree_GetChildAtIndex(tree, i));
+ M._FPDF_StructTree_Close(tree);
+ const out = new Map();
+ const key = M._malloc(8);
+ M.stringToUTF8('MCID', key, 8);
+ const val = M._malloc(4);
+ for (let i = 0; i < this.objectCount(); i++) {
+ const o = this.objectAt(i);
+ if (!o) continue;
+ const nm = M._FPDFPageObj_CountMarks(o.handle);
+ for (let k = 0; k < nm; k++) {
+ const mk = M._FPDFPageObj_GetMark(o.handle, k);
+ if (!mk) continue;
+ const nb = M._malloc(32),
+ need = M._malloc(4);
+ let name = '';
+ if (M._FPDFPageObjMark_GetName(mk, nb, 32, need)) {
+ const len = M.HEAPU32[need >> 2];
+ for (let q = 0; q + 2 <= len; q += 2) {
+ const c = M.HEAPU8[nb + q] | (M.HEAPU8[nb + q + 1] << 8);
+ if (c) name += String.fromCharCode(c);
+ }
+ }
+ M._free(nb);
+ M._free(need);
+ if (name !== 'P' && name !== 'Figure') continue;
+ if (!M._FPDFPageObjMark_GetParamIntValue(mk, key, val)) continue;
+ const mcid = M.HEAP32[val >> 2];
+ if (mcid >= 0 && !registered.has(mcid))
+ out.set(mcid, { mcid, type: name });
+ }
+ }
+ M._free(key);
+ M._free(val);
+ return [...out.values()].sort((a, b) => a.mcid - b.mcid);
+ }
+
+ objectMcid(handle) {
+ const M = this.M;
+ const key = M._malloc(8);
+ M.stringToUTF8('MCID', key, 8);
+ const val = M._malloc(4);
+ let mcid = -1;
+ const nm = M._FPDFPageObj_CountMarks(handle);
+ for (let k = 0; k < nm && mcid < 0; k++) {
+ const mk = M._FPDFPageObj_GetMark(handle, k);
+ if (mk && M._FPDFPageObjMark_GetParamIntValue(mk, key, val)) {
+ mcid = M.HEAP32[val >> 2];
+ }
+ }
+ M._free(key);
+ M._free(val);
+ return mcid;
+ }
+
+ tagObject(handle, name) {
+ this._pageDirty = true;
+ const M = this.M;
+ let maxM = -1;
+ for (let i = 0; i < this.objectCount(); i++) {
+ const o = this.objectAt(i);
+ if (o) maxM = Math.max(maxM, this.objectMcid(o.handle));
+ }
+ const nbuf = M._malloc(name.length + 1);
+ M.stringToUTF8(name, nbuf, name.length + 1);
+ const mk = M._FPDFPageObj_AddMark(handle, nbuf);
+ let ok = false;
+ if (mk) {
+ const key = M._malloc(8);
+ M.stringToUTF8('MCID', key, 8);
+ ok = !!M._FPDFPageObjMark_SetIntParam(
+ this.doc,
+ handle,
+ mk,
+ key,
+ maxM + 1
+ );
+ M._free(key);
+ }
+ M._free(nbuf);
+ return ok;
+ }
+
+ pageIsTagged() {
+ const M = this.M;
+ const tree = M._FPDF_StructTree_GetForPage(this.page);
+ if (!tree) return false;
+ const n = M._FPDF_StructTree_CountChildren(tree);
+ M._FPDF_StructTree_Close(tree);
+ return n > 0;
+ }
+
+ structAltFor(mcid) {
+ const M = this.M;
+ const tree = M._FPDF_StructTree_GetForPage(this.page);
+ if (!tree) return null;
+ let found = null;
+ const walk = (el) => {
+ if (found !== null) return;
+ if (M._FPDF_StructElement_GetMarkedContentID(el) === mcid) {
+ const need = M._FPDF_StructElement_GetAltText(el, 0, 0);
+ let txt = '';
+ if (need > 2) {
+ const buf = M._malloc(need);
+ M._FPDF_StructElement_GetAltText(el, buf, need);
+ for (let q = 0; q + 2 <= need - 2; q += 2) {
+ const c = M.HEAPU8[buf + q] | (M.HEAPU8[buf + q + 1] << 8);
+ if (c) txt += String.fromCharCode(c);
+ }
+ M._free(buf);
+ }
+ found = txt;
+ return;
+ }
+ const n = M._FPDF_StructElement_CountChildren(el);
+ for (let i = 0; i < n; i++) {
+ const ch = M._FPDF_StructElement_GetChildAtIndex(el, i);
+ if (ch) walk(ch);
+ }
+ };
+ const n = M._FPDF_StructTree_CountChildren(tree);
+ for (let i = 0; i < n; i++)
+ walk(M._FPDF_StructTree_GetChildAtIndex(tree, i));
+ M._FPDF_StructTree_Close(tree);
+ return found;
+ }
+
+ collectShadings() {
+ const out = [];
+ let firstOther = -1;
+ for (let i = 0; i < this.objectCount(); i++) {
+ const o = this.objectAt(i);
+ if (!o) continue;
+ if (o.type === 4) out.push({ index: i });
+ else if (firstOther < 0) firstOther = i;
+ }
+ return out.map((s2) => ({
+ ...s2,
+ isBackground: firstOther < 0 || s2.index < firstOther,
+ }));
+ }
+
+ pathStyle(handle) {
+ const M = this.M;
+ const p = M._malloc(24);
+ const st = { stroke: false, strokeRgba: 0, strokeWidth: 1, fill: false };
+ if (M._FPDFPath_GetDrawMode(handle, p, p + 4)) {
+ st.fill = M.HEAP32[p >> 2] !== 0;
+ st.stroke = M.HEAP32[(p + 4) >> 2] !== 0;
+ }
+ if (M._FPDFPageObj_GetStrokeColor(handle, p, p + 4, p + 8, p + 12)) {
+ st.strokeRgba =
+ (((M.HEAPU32[p >> 2] & 0xff) << 24) |
+ ((M.HEAPU32[(p + 4) >> 2] & 0xff) << 16) |
+ ((M.HEAPU32[(p + 8) >> 2] & 0xff) << 8) |
+ (M.HEAPU32[(p + 12) >> 2] & 0xff)) >>>
+ 0;
+ }
+ if (M._FPDFPageObj_GetStrokeWidth(handle, p)) {
+ st.strokeWidth = M.HEAPF32[p >> 2];
+ }
+ M._free(p);
+ return st;
+ }
+
+ setPathStroke(handle, rgba, width) {
+ this._pageDirty = true;
+ const M = this.M;
+ const p = M._malloc(8);
+ let fill = 0;
+ if (M._FPDFPath_GetDrawMode(handle, p, p + 4)) fill = M.HEAP32[p >> 2];
+ M._free(p);
+ M._FPDFPageObj_SetStrokeColor(
+ handle,
+ (rgba >>> 24) & 0xff,
+ (rgba >>> 16) & 0xff,
+ (rgba >>> 8) & 0xff,
+ rgba & 0xff
+ );
+ M._FPDFPageObj_SetStrokeWidth(handle, width > 0 ? width : 1);
+ M._FPDFPath_SetDrawMode(handle, fill, 1);
+ }
+
+ removeObject(handle) {
+ this._pageDirty = true;
+ if (this.M._FPDFPage_RemoveObject(this.page, handle)) {
+ this.M._FPDFPageObj_Destroy(handle);
+ return true;
+ }
+ return false;
+ }
+
+ scaleObject(handle, sx, sy, anchorX, anchorY) {
+ ({ x: anchorX, y: anchorY } = this.toUser(anchorX, anchorY));
+ if (this.pageT && Math.abs(this.pageT[0]) < 0.5) {
+ const t2 = sx;
+ sx = sy;
+ sy = t2;
+ }
+ this._pageDirty = true;
+ const M = this.M;
+ const m = M._malloc(24);
+ M.HEAPF32[(m + 0) >> 2] = sx;
+ M.HEAPF32[(m + 4) >> 2] = 0;
+ M.HEAPF32[(m + 8) >> 2] = 0;
+ M.HEAPF32[(m + 12) >> 2] = sy;
+ M.HEAPF32[(m + 16) >> 2] = anchorX - sx * anchorX;
+ M.HEAPF32[(m + 20) >> 2] = anchorY - sy * anchorY;
+ M._FPDFPageObj_TransformClipPath(
+ handle,
+ sx,
+ 0,
+ 0,
+ sy,
+ anchorX - sx * anchorX,
+ anchorY - sy * anchorY
+ );
+ M._FPDFPageObj_TransformF(handle, m);
+ M._free(m);
+ }
+
+ currentIndexOf(handle) {
+ for (let i = 0; i < this.objectCount(); i++)
+ if (this.M._FPDFPage_GetObject(this.page, i) === handle) return i;
+ return -1;
+ }
+
+ arrangeObject(handle, op) {
+ this._pageDirty = true;
+ const M = this.M;
+ const idx = this.currentIndexOf(handle);
+ if (idx < 0) return false;
+ const last = this.objectCount() - 1;
+ const target =
+ op === 'front'
+ ? last
+ : op === 'back'
+ ? 0
+ : op === 'forward'
+ ? Math.min(last, idx + 1)
+ : Math.max(0, idx - 1);
+ if (target === idx) return false;
+ if (!M._FPDFPage_RemoveObject(this.page, handle)) return false;
+ if (!M._FPDFPage_InsertObjectAtIndex(this.page, handle, target))
+ M._FPDFPage_InsertObject(this.page, handle);
+ return true;
+ }
+
+ insertImage(rgba, pxW, pxH, x, y, w, h) {
+ this._pageDirty = true;
+ const M = this.M;
+ const handle = M._FPDFPageObj_NewImageObj(this.doc);
+ if (!handle) return 0;
+ const FPDFBitmap_BGRA = 4;
+ const bmp = M._FPDFBitmap_Create(pxW, pxH, 1);
+ const buf = M._FPDFBitmap_GetBuffer(bmp);
+ const stride = M._FPDFBitmap_GetStride(bmp);
+ for (let row = 0; row < pxH; row++) {
+ let src = row * pxW * 4,
+ dst = buf + row * stride;
+ for (let col = 0; col < pxW; col++) {
+ M.HEAPU8[dst] = rgba[src + 2];
+ M.HEAPU8[dst + 1] = rgba[src + 1];
+ M.HEAPU8[dst + 2] = rgba[src];
+ M.HEAPU8[dst + 3] = rgba[src + 3];
+ src += 4;
+ dst += 4;
+ }
+ }
+ const pagesPtr = M._malloc(4);
+ M.HEAPU32[pagesPtr >> 2] = this.page;
+ M._FPDFImageObj_SetBitmap(pagesPtr, 1, handle, bmp);
+ M._free(pagesPtr);
+ M._FPDFBitmap_Destroy(bmp);
+ const m = M._malloc(24);
+ M.HEAPF32[(m + 0) >> 2] = w;
+ M.HEAPF32[(m + 4) >> 2] = 0;
+ M.HEAPF32[(m + 8) >> 2] = 0;
+ M.HEAPF32[(m + 12) >> 2] = h;
+ const at = this.toUser(x, y);
+ M.HEAPF32[(m + 16) >> 2] = at.x;
+ M.HEAPF32[(m + 20) >> 2] = at.y;
+ M._FPDFPageObj_SetMatrix(handle, m);
+ M._free(m);
+ M._FPDFPage_InsertObject(this.page, handle);
+ return handle;
+ }
+
+ duplicateImage(handle, dx, dy) {
+ this._pageDirty = true;
+ const M = this.M;
+ const b = this.objectBounds(handle);
+ if (!b) return 0;
+ const sz = M._malloc(8);
+ let iw = 0,
+ ih = 0;
+ if (M._FPDFImageObj_GetImagePixelSize(handle, sz, sz + 4)) {
+ iw = M.HEAPU32[sz >> 2];
+ ih = M.HEAPU32[(sz + 4) >> 2];
+ }
+ M._free(sz);
+ const mp = M._malloc(24);
+ let saved = null;
+ if (M._FPDFPageObj_GetMatrix(handle, mp)) {
+ saved = [0, 1, 2, 3, 4, 5].map((i) => M.HEAPF32[(mp >> 2) + i]);
+ }
+ const upright =
+ saved && saved[1] === 0 && saved[2] === 0 && saved[0] > 0 && saved[3] > 0;
+ let boosted = false;
+ if (upright && iw > 0 && ih > 0 && (iw > saved[0] || ih > saved[3])) {
+ const s = Math.min(1, 4096 / Math.max(iw, ih));
+ M.HEAPF32.set([iw * s, 0, 0, ih * s, saved[4], saved[5]], mp >> 2);
+ M._FPDFPageObj_SetMatrix(handle, mp);
+ boosted = true;
+ }
+ const bmp = M._FPDFImageObj_GetRenderedBitmap(this.doc, this.page, handle);
+ if (boosted) {
+ M.HEAPF32.set(saved, mp >> 2);
+ M._FPDFPageObj_SetMatrix(handle, mp);
+ }
+ M._free(mp);
+ if (!bmp) return 0;
+ const w = M._FPDFBitmap_GetWidth(bmp),
+ h = M._FPDFBitmap_GetHeight(bmp);
+ const stride = M._FPDFBitmap_GetStride(bmp),
+ buf = M._FPDFBitmap_GetBuffer(bmp);
+ const rgba = new Uint8Array(w * h * 4);
+ for (let row = 0; row < h; row++) {
+ let src = buf + row * stride,
+ dst = row * w * 4;
+ for (let col = 0; col < w; col++) {
+ rgba[dst] = M.HEAPU8[src + 2];
+ rgba[dst + 1] = M.HEAPU8[src + 1];
+ rgba[dst + 2] = M.HEAPU8[src];
+ rgba[dst + 3] = M.HEAPU8[src + 3];
+ src += 4;
+ dst += 4;
+ }
+ }
+ M._FPDFBitmap_Destroy(bmp);
+ return this.insertImage(rgba, w, h, b.x + dx, b.y + dy, b.w, b.h);
+ }
+
+ objectFill(handle) {
+ const M = this.M;
+ const p = M._malloc(16);
+ const ok = M._FPDFPageObj_GetFillColor(handle, p, p + 4, p + 8, p + 12);
+ const r = M.HEAPU32[p >> 2],
+ g = M.HEAPU32[(p + 4) >> 2];
+ const b = M.HEAPU32[(p + 8) >> 2],
+ a = M.HEAPU32[(p + 12) >> 2];
+ M._free(p);
+ return ok ? { r, g, b, a } : null;
+ }
+ setObjectFill(handle, r, g, b, a) {
+ this._pageDirty = true;
+ this.M._FPDFPageObj_SetFillColor(handle, r, g, b, a);
+ }
+
+ renderPage(scale, hideHandles = null) {
+ const M = this.M;
+ const s = Math.min(
+ scale,
+ 4096 / Math.max(1, this.pageWidth),
+ 4096 / Math.max(1, this.pageHeight)
+ );
+ scale = Math.max(0.05, s);
+ const w = Math.max(1, Math.round(this.pageWidth * scale));
+ const h = Math.max(1, Math.round(this.pageHeight * scale));
+ let saved = null,
+ mp = 0;
+ if (hideHandles && hideHandles.length) {
+ mp = M._malloc(24);
+ saved = [];
+ for (const handle of hideHandles) {
+ if (!M._FPDFPageObj_GetMatrix(handle, mp)) continue;
+ saved.push([handle, M.HEAPF32.slice(mp >> 2, (mp >> 2) + 6)]);
+ M.HEAPF32[(mp + 0) >> 2] = 1e-6;
+ M.HEAPF32[(mp + 4) >> 2] = 0;
+ M.HEAPF32[(mp + 8) >> 2] = 0;
+ M.HEAPF32[(mp + 12) >> 2] = 1e-6;
+ M.HEAPF32[(mp + 16) >> 2] = -1e6;
+ M.HEAPF32[(mp + 20) >> 2] = -1e6;
+ M._FPDFPageObj_SetMatrix(handle, mp);
+ }
+ }
+ const FPDFBitmap_BGRA = 4;
+ const bmp = M._FPDFBitmap_CreateEx(w, h, FPDFBitmap_BGRA, 0, 0);
+ M._FPDFBitmap_FillRect(bmp, 0, 0, w, h, 0xffffffff);
+ const FPDF_ANNOT = 0x01,
+ FPDF_LCD_TEXT = 0x02,
+ FPDF_REVERSE_BYTE_ORDER = 0x10;
+ const FLAGS = FPDF_ANNOT | FPDF_LCD_TEXT | FPDF_REVERSE_BYTE_ORDER;
+ M._FPDF_RenderPageBitmap(bmp, this.page, 0, 0, w, h, 0, FLAGS);
+ M._ec_form_draw(this.session, this.page, bmp, 0, 0, w, h, 0, FLAGS);
+ if (saved) {
+ for (const [handle, m] of saved) {
+ M.HEAPF32.set(m, mp >> 2);
+ M._FPDFPageObj_SetMatrix(handle, mp);
+ }
+ M._free(mp);
+ }
+ const buf = M._FPDFBitmap_GetBuffer(bmp);
+ const stride = M._FPDFBitmap_GetStride(bmp);
+ const need = w * h * 4;
+ if (this._renderScratch?.length !== need)
+ this._renderScratch = new Uint8ClampedArray(need);
+ const out = this._renderScratch;
+ if (stride === w * 4) {
+ out.set(M.HEAPU8.subarray(buf, buf + w * h * 4));
+ } else {
+ for (let y = 0; y < h; y++) {
+ out.set(
+ M.HEAPU8.subarray(buf + y * stride, buf + y * stride + w * 4),
+ y * w * 4
+ );
+ }
+ }
+ M._FPDFBitmap_Destroy(bmp);
+ return { width: w, height: h, data: out };
+ }
+
+ generateContent() {
+ if (!this._pageDirty) return true;
+ if (this._t3seg && this._t3seg[this.pageIndex]) {
+ const st = this._t3readState();
+ if (st) {
+ this._t3anchor(st);
+ (this._t3state ||= {})[this.pageIndex] = st.filter(
+ (o) => o.t3 && !o.inForm && o.glyphs && o.glyphs.length
+ );
+ }
+ }
+ if (this.session) this.M._ec_dealias_page_fonts(this.session, this.page);
+ this.M._ec_normalize_page_paint(this.page);
+ const ok = this.M._FPDFPage_GenerateContent(this.page) !== 0;
+ if (ok) this._spliceOk = false;
+ if (ok && this._usesPattern) this._pageStale = true;
+ if (ok) {
+ this._pageDirty = false;
+ (this._editedPages ||= new Set()).add(this.pageIndex);
+ }
+ return ok;
+ }
+
+ async saveSpliced(opts) {
+ if (
+ this._spliceOk === false ||
+ !this._splicePlans?.length ||
+ !this._originalBytes ||
+ this._reencoded?.size ||
+ this._normalizedBytes ||
+ this._encrypted
+ )
+ return this.save(opts);
+ try {
+ const { applyPlan } = await import('./streamsplice.js');
+ const writeCount = this._splicePlans.filter(
+ (e) => e.plan?.kind === 'write'
+ ).length;
+ if (writeCount > 1) return this.save(opts);
+ let bytes = this._originalBytes;
+ for (const { page, plan } of this._splicePlans) {
+ const next = await applyPlan(bytes, page, plan);
+ if (!next) return this.save(opts);
+ bytes = next;
+ }
+ return bytes;
+ } catch {
+ return this.save(opts);
+ }
+ }
+
+ renderParagraphLiveEnd() {
+ if (this.M._ec_render_paragraph_live_end && this.session && this.page) {
+ this.M._ec_render_paragraph_live_end(this.session, this.page);
+ }
+ }
+
+ save(opts) {
+ this.renderParagraphLiveEnd();
+ if (this._pageDirty && !opts?.noRegen) this.generateContent();
+ const M = this.M;
+ const sizePtr = M._malloc(4);
+ const hasT3 = this._t3seg && Object.keys(this._t3seg).length > 0;
+ const incremental =
+ !this._encrypted && ((opts && opts.incremental) || hasT3);
+ const outPtr = M._ec_save_document(this.doc, incremental ? 1 : 2, sizePtr);
+ const size = M.HEAPU32[sizePtr >> 2];
+ M._free(sizePtr);
+ if (!outPtr || !size) return null;
+ let bytes = M.HEAPU8.slice(outPtr, outPtr + size);
+ M._ec_string_free(outPtr);
+ if (hasT3 && this._t3state) {
+ try {
+ const jobs = Object.entries(this._t3state).map(([pi, state]) => ({
+ pageIndex: Number(pi),
+ seg: this._t3seg[pi],
+ state,
+ anchors: this._t3anchors?.[pi] || {},
+ }));
+ const fixed = applyType3LeftoverSurgery(bytes, jobs);
+ if (fixed) {
+ bytes = fixed.bytes;
+ for (const [pi, tjEntries] of Object.entries(fixed.injected)) {
+ this._t3seg[pi]?.tjs?.push(...tjEntries);
+ }
+ }
+ } catch (e) {
+ console.warn('type3 surgery skipped:', e);
+ }
+ }
+ return bytes;
+ }
+}
+
+const OBJECT_MUTATORS = [
+ 'open',
+ 'reopen',
+ 'loadPage',
+ 'buildModel',
+ 'generateContent',
+ 'normalizeFontsForEdit',
+ 'addParagraph',
+ 'commitParagraph',
+ 'deleteParagraph',
+ 'moveParagraph',
+ 'resizeParagraph',
+ 'duplicateParagraph',
+ 'synthRunFont',
+ 'translateObject',
+ 'transformObjectAboutCenter',
+ 'rotateObject',
+ 'rotateObjectsAbout',
+ 'flipObject',
+ 'scaleObject',
+ 'arrangeObject',
+ 'removeObject',
+ 'replaceImage',
+ 'insertImage',
+ 'duplicateImage',
+ 'setPathStroke',
+ 'setObjectFill',
+ 'tagObject',
+ 'historyUndo',
+ 'historyRedo',
+];
+
+for (const name of OBJECT_MUTATORS) {
+ const original = PdfEngine.prototype[name];
+ if (typeof original !== 'function') continue;
+ PdfEngine.prototype[name] = function wrapped(...args) {
+ this._objEpoch = (this._objEpoch || 0) + 1;
+ return original.apply(this, args);
+ };
+}
diff --git a/src/js/editcore/engine-loader.js b/src/js/editcore/engine-loader.js
new file mode 100644
index 00000000..016a4082
--- /dev/null
+++ b/src/js/editcore/engine-loader.js
@@ -0,0 +1,37 @@
+import createModule from 'bentopdf-pdfium';
+
+const inBrowser = typeof window !== 'undefined';
+
+let ENGINE_VERSION = 'dev';
+try {
+ ENGINE_VERSION = __ENGINE_VERSION__;
+} catch {
+ ENGINE_VERSION = 'dev';
+}
+
+function resolveWasmUrl() {
+ if (inBrowser) {
+ const url = new URL('bentopdf-pdfium/editcore.wasm', import.meta.url);
+ if (import.meta.env?.DEV) {
+ url.searchParams.set('v', ENGINE_VERSION);
+ }
+ return url.href;
+ }
+ const resolve = import.meta.resolve;
+ if (typeof resolve !== 'function') return null;
+ return new URL(resolve('bentopdf-pdfium/editcore.wasm')).pathname;
+}
+
+const wasmUrl = resolveWasmUrl();
+
+export const ENGINE_BUILD = `bentopdf-pdfium@${ENGINE_VERSION}`;
+
+export function createEngineModule(options) {
+ return createModule({
+ ...(options ?? {}),
+ locateFile: (file, prefix) =>
+ file.endsWith('.wasm')
+ ? (wasmUrl ?? `${prefix}editcore.wasm`)
+ : `${prefix}${file}`,
+ });
+}
diff --git a/src/js/editcore/shadingsurgery.js b/src/js/editcore/shadingsurgery.js
new file mode 100644
index 00000000..6bc105d3
--- /dev/null
+++ b/src/js/editcore/shadingsurgery.js
@@ -0,0 +1,796 @@
+export const bytesHave = (u8, needle) => {
+ const n = needle.length;
+ const end = u8.length;
+ if (!n || end < n) return false;
+ const first = needle.charCodeAt(0);
+ outer: for (let i = 0; i + n <= end; i++) {
+ if (u8[i] !== first) continue;
+ for (let j = 1; j < n; j++) {
+ if (u8[i + j] !== needle.charCodeAt(j)) continue outer;
+ }
+ return true;
+ }
+ return false;
+};
+
+const PROBE_NEEDLES = [
+ ['pattern', '/Pattern'],
+ ['shading', '/Shading'],
+ ['type3', '/Type3'],
+ ['form', '/Form'],
+ ['quartz', 'Quartz PDFContext'],
+ ['objStm', '/ObjStm'],
+];
+
+const probeMemo = new WeakMap();
+
+export const probePdfBytes = (u8) => {
+ const cached = probeMemo.get(u8);
+ if (cached) return cached;
+ const out = {
+ pattern: false,
+ shading: false,
+ type3: false,
+ form: false,
+ quartz: false,
+ objStm: false,
+ contentsArray: false,
+ subsetFont: false,
+ };
+ const end = u8.length;
+ const match = (at, text) => {
+ if (at + text.length > end) return false;
+ for (let j = 1; j < text.length; j++) {
+ if (u8[at + j] !== text.charCodeAt(j)) return false;
+ }
+ return true;
+ };
+ const skipWs = (at) => {
+ while (
+ at < end &&
+ (u8[at] === 32 || u8[at] === 10 || u8[at] === 13 || u8[at] === 9)
+ )
+ at++;
+ return at;
+ };
+ for (let i = 0; i < end; i++) {
+ const c = u8[i];
+ if (c === 81) {
+ if (!out.quartz && match(i, 'Quartz PDFContext')) out.quartz = true;
+ continue;
+ }
+ if (c !== 47) continue;
+ for (const [key, text] of PROBE_NEEDLES) {
+ if (!out[key] && match(i, text)) out[key] = true;
+ }
+ if (!out.contentsArray && match(i, '/Contents')) {
+ if (u8[skipWs(i + 9)] === 91) out.contentsArray = true;
+ }
+ if (!out.subsetFont && match(i, '/BaseFont')) {
+ let k = skipWs(i + 9);
+ if (u8[k] === 47) {
+ k++;
+ let upper = k + 6 <= end;
+ for (let t = 0; upper && t < 6; t++) {
+ if (u8[k + t] < 65 || u8[k + t] > 90) upper = false;
+ }
+ if (upper && u8[k + 6] === 43) out.subsetFont = true;
+ }
+ }
+ }
+ probeMemo.set(u8, out);
+ return out;
+};
+
+const latinMemo = new WeakMap();
+export const latin = (u8) => {
+ const hit = latinMemo.get(u8);
+ if (hit) return hit;
+ let s = '';
+ for (let i = 0; i < u8.length; i += 0x8000) {
+ s += String.fromCharCode.apply(
+ null,
+ u8.subarray(i, Math.min(i + 0x8000, u8.length))
+ );
+ }
+ latinMemo.set(u8, s);
+ return s;
+};
+
+const parsePdfMemo = new Map();
+
+export const parsePdf = (src) => {
+ const hit = parsePdfMemo.get(src);
+ if (hit !== undefined) return hit;
+ const built = parsePdfUncached(src);
+ if (parsePdfMemo.size > 4) parsePdfMemo.clear();
+ parsePdfMemo.set(src, built);
+ return built;
+};
+
+const parsePdfUncached = (src) => {
+ const objAt = new Map();
+ for (const m of src.matchAll(/(\d+)\s+0\s+obj\b/g)) {
+ if (m.index > 0 && src[m.index - 1] >= '0' && src[m.index - 1] <= '9')
+ continue;
+ objAt.set(parseInt(m[1], 10), m.index);
+ }
+ const objBody = (num) => {
+ const at = objAt.get(num);
+ if (at == null) return null;
+ const start = src.indexOf('obj', at) + 3;
+ const end = src.indexOf('endobj', start);
+ return end < 0 ? null : { start, end, body: src.slice(start, end) };
+ };
+ const dictRef = (body, key) => {
+ const m = body.match(new RegExp('/' + key + '\\s+(\\d+)\\s+\\d+\\s+R'));
+ return m ? parseInt(m[1], 10) : -1;
+ };
+ let rootNum = -1;
+ let size = -1;
+ for (let tAt = src.length; rootNum < 0; ) {
+ tAt = src.lastIndexOf('trailer', tAt - 1);
+ if (tAt < 0) break;
+ const trailer = src.slice(tAt, tAt + 400);
+ if (rootNum < 0) rootNum = dictRef(trailer, 'Root');
+ }
+ for (const m of src.matchAll(/\/Size\s+(\d+)/g))
+ size = Math.max(size, parseInt(m[1], 10));
+ if (rootNum < 0) {
+ const m = src.match(/\/Root\s+(\d+)\s+\d+\s+R/);
+ if (m) rootNum = parseInt(m[1], 10);
+ }
+ if (rootNum < 0 || size < 0) return null;
+ let pageList = null;
+ const pageNum = (pageIndex) => {
+ if (!pageList) {
+ pageList = [];
+ const root = objBody(rootNum);
+ if (!root) return -1;
+ const seen = new Set();
+ const walk = (num) => {
+ if (seen.has(num) || pageList.length > 8192) return;
+ seen.add(num);
+ const o = objBody(num);
+ if (!o) return;
+ if (/\/Type\s*\/Page\b(?!s)/.test(o.body)) {
+ pageList.push(num);
+ return;
+ }
+ const kids = o.body.match(/\/Kids\s*\[([^\]]*)\]/);
+ if (!kids) return;
+ for (const r of kids[1].matchAll(/(\d+)\s+\d+\s+R/g))
+ walk(parseInt(r[1], 10));
+ };
+ walk(dictRef(root.body, 'Pages'));
+ }
+ return pageIndex < pageList.length ? pageList[pageIndex] : -1;
+ };
+ return { objBody, dictRef, rootNum, size, pageNum };
+};
+
+export const streamData = async (bytes, src, obj) => {
+ const si = src.indexOf('stream', obj.start);
+ if (si < 0 || si > obj.end) return null;
+ let ds = si + 6;
+ if (src[ds] === '\r') ds++;
+ if (src[ds] === '\n') ds++;
+ const indirect = obj.body.match(/\/Length\s+(\d+)\s+\d+\s+R/);
+ let len = -1;
+ if (indirect) {
+ const rx = new RegExp(
+ '(?:^|[^0-9])' + indirect[1] + '\\s+0\\s+obj\\s*(\\d+)'
+ );
+ const rm = src.match(rx);
+ if (rm) len = parseInt(rm[1], 10);
+ } else {
+ const lm = obj.body.match(/\/Length\s+(\d+)/);
+ if (lm) len = parseInt(lm[1], 10);
+ }
+ const de = len >= 0 ? ds + len : src.indexOf('endstream', ds);
+ const raw = bytes.subarray(ds, de);
+ if (!/\/Filter\s*\/FlateDecode/.test(obj.body)) {
+ return /\/Filter/.test(obj.body) ? null : latin(raw);
+ }
+ try {
+ const stream = new Blob([raw])
+ .stream()
+ .pipeThrough(new DecompressionStream('deflate'));
+ const buf = new Uint8Array(await new Response(stream).arrayBuffer());
+ return latin(buf);
+ } catch {
+ return null;
+ }
+};
+
+const objStmIndex = async (bytes, src) => {
+ const out = new Map();
+ for (const m of src.matchAll(/(\d+)\s+0\s+obj\b/g)) {
+ const start = src.indexOf('obj', m.index) + 3;
+ const end = src.indexOf('endobj', start);
+ if (end < 0) continue;
+ const body = src.slice(start, end);
+ if (!/\/Type\s*\/ObjStm/.test(body)) continue;
+ const data = await streamData(bytes, src, { start, end, body });
+ if (!data) continue;
+ const n = parseInt((body.match(/\/N\s+(\d+)/) || [])[1], 10);
+ const first = parseInt((body.match(/\/First\s+(\d+)/) || [])[1], 10);
+ if (!Number.isFinite(n) || !Number.isFinite(first)) continue;
+ const nums = data.slice(0, first).trim().split(/\s+/).map(Number);
+ for (let i = 0; i < n; i++) {
+ const num = nums[i * 2],
+ off = nums[i * 2 + 1];
+ if (!Number.isFinite(num) || !Number.isFinite(off)) continue;
+ const nextOff = i + 1 < n ? nums[i * 2 + 3] : data.length - first;
+ out.set(
+ num,
+ data.slice(
+ first + off,
+ first + (Number.isFinite(nextOff) ? nextOff : data.length - first)
+ )
+ );
+ }
+ }
+ return out;
+};
+
+const objStmMemo = new WeakMap();
+
+const objStmIndexCached = async (bytes, src) => {
+ const hit = objStmMemo.get(bytes);
+ if (hit) return hit;
+ const built = await objStmIndex(bytes, src);
+ objStmMemo.set(bytes, built);
+ return built;
+};
+
+export const withObjStm = async (bytes, src, op) => {
+ if (!op) return op;
+ const idx = await objStmIndexCached(bytes, src);
+ if (!idx.size) return op;
+ const base = op.objBody;
+ const objBody = (num) =>
+ base(num) ||
+ (idx.has(num) ? { start: -1, end: -1, body: idx.get(num) } : null);
+ const dictRef = op.dictRef;
+ let pageList = null;
+ const pageNum = (index) => {
+ if (!pageList) {
+ pageList = [];
+ const root = objBody(op.rootNum);
+ if (!root) return -1;
+ const seen = new Set();
+ const walk = (num, depth) => {
+ if (num < 0 || depth > 32 || seen.has(num) || pageList.length > 8192)
+ return;
+ seen.add(num);
+ const o = objBody(num);
+ if (!o) return;
+ if (/\/Type\s*\/Page(?![sA-Za-z])/.test(o.body)) {
+ pageList.push(num);
+ return;
+ }
+ const kids = o.body.match(/\/Kids\s*\[([^\]]*)\]/);
+ if (!kids) return;
+ for (const km of kids[1].matchAll(/(\d+)\s+\d+\s+R/g))
+ walk(parseInt(km[1], 10), depth + 1);
+ };
+ walk(dictRef(root.body, 'Pages'), 0);
+ }
+ return index < pageList.length ? pageList[index] : -1;
+ };
+ return { ...op, objBody, pageNum };
+};
+
+const openQBefore = (content, pos, windowBytes) => {
+ const backStart = Math.max(0, pos - windowBytes);
+ const back = content.slice(backStart, pos);
+ let depth = 0;
+ for (let i = back.length - 1; i >= 0; i--) {
+ const c = back[i];
+ if (c !== 'q' && c !== 'Q') continue;
+ const prev = i > 0 ? back[i - 1] : ' ';
+ const next = i + 1 < back.length ? back[i + 1] : ' ';
+ if (!/[\s>\]]/.test(prev) || !/[\s\/<[(%]/.test(next)) continue;
+ if (c === 'Q') depth++;
+ else if (depth > 0) depth--;
+ else return backStart + i;
+ }
+ return -1;
+};
+
+const STATE_OPS = new Set([
+ 'm',
+ 'l',
+ 'c',
+ 'v',
+ 'y',
+ 're',
+ 'h',
+ 'W',
+ 'W*',
+ 'n',
+ 'cm',
+ 'gs',
+ 'ri',
+ 'i',
+ 'j',
+ 'J',
+ 'M',
+ 'd',
+ 'w',
+ 'cs',
+ 'CS',
+ 'sc',
+ 'scn',
+ 'SC',
+ 'SCN',
+ 'g',
+ 'G',
+ 'rg',
+ 'RG',
+ 'k',
+ 'K',
+ 'BX',
+ 'EX',
+]);
+const gapIsStateOnly = (gap) => {
+ const stripped = gap
+ .replace(/%[^\r\n]*/g, ' ')
+ .replace(/\((?:\\.|[^\\)])*\)/g, ' ')
+ .replace(/<<[\s\S]*?>>/g, ' ')
+ .replace(/<[0-9A-Fa-f\s]*>/g, ' ')
+ .replace(/\/[A-Za-z0-9_.#-]*/g, ' ')
+ .replace(/[+-]?\d*\.?\d+/g, ' ')
+ .replace(/[[\]]/g, ' ');
+ for (const tok of stripped.split(/\s+/)) {
+ if (tok && !STATE_OPS.has(tok)) return false;
+ }
+ return true;
+};
+
+const shBlocks = (content) => {
+ const blocks = [];
+ for (const m of content.matchAll(
+ /\/[A-Za-z0-9_.]+\s+(?:sh|scn)(?![a-zA-Z])/g
+ )) {
+ let qAt = openQBefore(content, m.index, 4096);
+ let endAt = -1;
+ const fwd = content.slice(m.index, m.index + 2048);
+ const qm = fwd.match(/[\s>\]]Q(?=[\s\/<[(%]|$)/);
+ if (qm) endAt = m.index + qm.index + qm[0].length;
+ if (qAt < 0 || endAt <= qAt) continue;
+ const innerStart = qAt,
+ innerEnd = endAt;
+ let ops = content.slice(qAt, endAt);
+ for (let lvl = 0; lvl < 4; lvl++) {
+ const outer = openQBefore(content, qAt, 8192);
+ if (outer < 0) break;
+ const gap = content.slice(outer + 1, qAt);
+ if (!gapIsStateOnly(gap)) break;
+ ops = content.slice(outer, qAt) + ops + '\nQ';
+ qAt = outer;
+ }
+ ops = stripTextObjects(ops);
+ if (!/\b(?:sh|scn)(?![a-zA-Z])/.test(ops)) continue;
+ blocks.push({
+ ops,
+ at: m.index,
+ endAt,
+ innerStart,
+ innerEnd,
+ isPattern: /scn$/.test(m[0].trim()),
+ });
+ }
+ return blocks;
+};
+
+const stripTextObjects = (ops) => {
+ const tok = /(?:^|[\s>\]<)(])(BT|ET)(?=[\s/<[(]|$)/g;
+ let out = '',
+ i = 0,
+ depth = 0;
+ for (const m of ops.matchAll(tok)) {
+ const at = m.index + (m[0].length - 2);
+ if (m[1] === 'BT') {
+ if (depth === 0) out += ops.slice(i, at);
+ depth++;
+ } else if (depth > 0) {
+ depth--;
+ if (depth === 0) i = at + 2;
+ }
+ }
+ if (depth === 0) out += ops.slice(i);
+ return out;
+};
+
+const classifyShadings = (content, blocks) => {
+ const btPos = content.search(/(?:^|[^A-Za-z0-9_])BT(?=[^A-Za-z0-9_]|$)/);
+ const doM = content.match(/\/[A-Za-z0-9_.]+\s+Do(?![a-zA-Z])/);
+ const doPos = doM ? doM.index : -1;
+ const firstContent = Math.min(
+ ...[btPos, doPos].filter((p) => p >= 0).concat([Infinity])
+ );
+ return blocks.map((b) => ({ isBackground: b.at < firstContent }));
+};
+
+const pageContent = async (bytes, src, parsed, pageIndex) => {
+ const pgNum = parsed.pageNum(pageIndex);
+ if (pgNum < 0) return null;
+ const pg = parsed.objBody(pgNum);
+ if (!pg) return null;
+ const one = pg.body.match(/\/Contents\s+(\d+)\s+\d+\s+R/);
+ const arr = pg.body.match(/\/Contents\s*\[([^\]]*)\]/);
+ const refs = one
+ ? [parseInt(one[1], 10)]
+ : arr
+ ? [...arr[1].matchAll(/(\d+)\s+\d+\s+R/g)].map((r) => parseInt(r[1], 10))
+ : [];
+ let content = '';
+ const spans = [];
+ for (const rn of refs) {
+ const o = parsed.objBody(rn);
+ const d = o && (await streamData(bytes, src, o));
+ if (d == null) return null;
+ spans.push({ start: content.length, end: content.length + d.length });
+ content += d + '\n';
+ }
+ return { content, spans };
+};
+
+export async function shadingsForPage(originalBytes, pageIndex) {
+ const orig = latin(originalBytes);
+ const op = await withObjStm(originalBytes, orig, parsePdf(orig));
+ if (!op) return [];
+ const pc = await pageContent(originalBytes, orig, op, pageIndex);
+ if (pc == null) return [];
+ const blocks = shBlocks(pc.content);
+ if (!blocks.length) return [];
+ return classifyShadings(pc.content, blocks);
+}
+
+export async function applyShadingSurgery(savedBytes, originalBytes, pages) {
+ if (!Array.isArray(pages)) pages = pages ? [pages] : [];
+ pages = pages.filter(Boolean);
+ if (!pages.length) return null;
+ const saved = latin(savedBytes);
+ const sp = await withObjStm(savedBytes, saved, parsePdf(saved));
+ if (!sp) return null;
+ const sx = saved.lastIndexOf('startxref');
+ const sxNum = parseInt(saved.slice(sx + 9).trim(), 10);
+ if (!Number.isFinite(sxNum) || saved.slice(sxNum, sxNum + 4) !== 'xref')
+ return null;
+
+ const orig = latin(originalBytes);
+ const op = await withObjStm(originalBytes, orig, parsePdf(orig));
+ if (!op) return null;
+
+ let next = sp.size;
+ const pageRewrites = [];
+ const contentObjs = [];
+
+ for (const pg of pages) {
+ const pageIndex = pg.pageIndex ?? 0;
+ const pc = await pageContent(originalBytes, orig, op, pageIndex);
+ if (pc == null) continue;
+ const { content, spans } = pc;
+ const allBlocks = shBlocks(content);
+ const blocks = allBlocks.filter((b) => b.isPattern);
+ if (!blocks.length) continue;
+ let shadings = pg.shadings;
+ if (!shadings || !shadings.length)
+ shadings = classifyShadings(content, blocks);
+ if (shadings.length !== blocks.length)
+ shadings = classifyShadings(content, blocks);
+ if (!shadings.length || blocks.length < shadings.length) continue;
+
+ const spgNum = sp.pageNum(pageIndex);
+ if (spgNum < 0) continue;
+ const spg = sp.objBody(spgNum);
+ let resBody = '';
+ const rref = spg.body.match(/\/Resources\s+(\d+)\s+\d+\s+R/);
+ if (rref) resBody = sp.objBody(parseInt(rref[1], 10))?.body ?? '';
+ else {
+ const k = spg.body.indexOf('/Resources');
+ resBody = k >= 0 ? spg.body.slice(k, k + 4000) : '';
+ }
+ const used = shadings.map((s2, k) => ({ ...s2, ops: blocks[k].ops }));
+ let resOk = true;
+ for (const u of used) {
+ for (const nm of u.ops.matchAll(
+ /\/([A-Za-z0-9_.]+)\s+(?:gs|sh|scn|SCN|Do)\b/g
+ )) {
+ if (!resBody.includes('/' + nm[1])) {
+ console.warn(
+ `[shading] resource /${nm[1]} missing from saved page ${pageIndex} — page skipped`
+ );
+ resOk = false;
+ break;
+ }
+ }
+ if (!resOk) break;
+ }
+ if (!resOk) continue;
+
+ const cOne = spg.body.match(/\/Contents\s+(\d+)\s+\d+\s+R/);
+ const cArr = spg.body.match(/\/Contents\s*\[([^\]]*)\]/);
+ const savedRefs = cOne
+ ? [cOne[1] + ' 0 R']
+ : cArr
+ ? [...cArr[1].matchAll(/\d+\s+\d+\s+R/g)].map((r) => r[0])
+ : [];
+ if (!savedRefs.length) continue;
+ const PAINT_RE =
+ /(?:^|[^A-Za-z0-9_*'"])(?:BT|Do|sh|EI|f\*?|F|B\*?|b\*?|S|s)(?![A-Za-z0-9_])/g;
+ const paints = (s2) => {
+ PAINT_RE.lastIndex = 0;
+ let n2 = 0;
+ while (PAINT_RE.exec(s2)) n2++;
+ return n2;
+ };
+ const slots = new Map();
+ const patternOps = [];
+ for (let k2 = 0; k2 < used.length; k2++) {
+ const u = used[k2];
+ const at = blocks[k2].at;
+ const si = spans.findIndex((s2) => at >= s2.start && at < s2.end);
+ const matched = si >= 0 && spans.length === savedRefs.length;
+ const before =
+ matched &&
+ paints(content.slice(spans[si].start, at)) <=
+ paints(content.slice(at, spans[si].end));
+ if (blocks[k2].isPattern) {
+ patternOps.push(u.ops);
+ continue;
+ }
+ let slot;
+ if (!matched) {
+ const textBefore = /(?:^|[^A-Za-z0-9_*'"])BT(?![A-Za-z0-9_])/.test(
+ content.slice(0, at)
+ );
+ slot = textBefore ? 2 * savedRefs.length : 0;
+ } else {
+ slot = before ? 2 * si : 2 * si + 2;
+ }
+ if (!slots.has(slot)) slots.set(slot, []);
+ slots.get(slot).push(u.ops);
+ }
+
+ let splitRefs = null;
+ if (patternOps.length && savedRefs.length === 1) {
+ const rnum = parseInt(savedRefs[0], 10);
+ const sobj = sp.objBody(rnum);
+ const sdata = sobj && (await streamData(savedBytes, saved, sobj));
+ let cut = -1;
+ const rectM = patternOps
+ .join('\n')
+ .match(/scn\s+(-?[\d.]+)\s+(-?[\d.]+)\s+(-?[\d.]+)\s+(-?[\d.]+)\s+re/);
+ if (sdata != null && rectM) {
+ const want = rectM.slice(1, 5).map(Number);
+ for (const rm of sdata.matchAll(
+ /(-?[\d.]+)\s+(-?[\d.]+)\s+(-?[\d.]+)\s+(-?[\d.]+)\s+re\b/g
+ )) {
+ const got = rm.slice(1, 5).map(Number);
+ if (!want.every((v, i) => Math.abs(v - got[i]) < 0.05)) continue;
+ const after = sdata.slice(rm.index);
+ const pm = after.match(
+ /(?:^|[^A-Za-z0-9_*'"])(?:f\*?|F|B\*?|b\*?|S|s|n)(?![A-Za-z0-9_])/
+ );
+ if (pm) cut = rm.index + pm.index + pm[0].length;
+ }
+ }
+ if (sdata != null && cut > 0) {
+ const mk = (body2) => {
+ const num = next++;
+ contentObjs.push({
+ num,
+ body: `<< /Length ${body2.length + 1} >>\nstream\n${body2}\nendstream`,
+ });
+ return `${num} 0 R`;
+ };
+ splitRefs = [
+ mk(sdata.slice(0, cut)),
+ mk(patternOps.join('\n')),
+ mk(sdata.slice(cut)),
+ ];
+ }
+ }
+ if (patternOps.length && !splitRefs) {
+ const slot = 0;
+ if (!slots.has(slot)) slots.set(slot, []);
+ slots.get(slot).push(...patternOps);
+ }
+ const refs = [];
+ if (splitRefs) refs.push(...splitRefs);
+ else
+ for (let i2 = 0; i2 <= savedRefs.length; i2++) {
+ const ops = slots.get(2 * i2);
+ if (ops && ops.length) {
+ const body2 = ops.join('\n');
+ const num = next++;
+ contentObjs.push({
+ num,
+ body: `<< /Length ${body2.length + 1} >>\nstream\n${body2}\nendstream`,
+ });
+ refs.push(`${num} 0 R`);
+ }
+ if (i2 < savedRefs.length) refs.push(savedRefs[i2]);
+ }
+ const newPage = spg.body.replace(
+ cOne ? cOne[0] : cArr[0],
+ `/Contents [ ${refs.join(' ')} ]`
+ );
+ pageRewrites.push({ num: spgNum, body: newPage });
+ }
+ if (!pageRewrites.length) return null;
+
+ let out = saved;
+ if (!out.endsWith('\n')) out += '\n';
+ const offsets = new Map();
+ const emit = (num, body) => {
+ offsets.set(num, out.length);
+ out += `${num} 0 obj\n${body.trim()}\nendobj\n`;
+ };
+ for (const o of pageRewrites) emit(o.num, o.body);
+ for (const o of contentObjs) emit(o.num, o.body);
+ const xrefAt = out.length;
+ const nums = [...offsets.keys()].sort((a, b) => a - b);
+ let xref = 'xref\n';
+ for (let i = 0; i < nums.length; ) {
+ let j = i;
+ while (j + 1 < nums.length && nums[j + 1] === nums[j] + 1) j++;
+ xref += `${nums[i]} ${j - i + 1}\n`;
+ for (let k = i; k <= j; k++)
+ xref += String(offsets.get(nums[k])).padStart(10, '0') + ' 00000 n \n';
+ i = j + 1;
+ }
+ out += xref;
+ const infoM = saved
+ .slice(saved.lastIndexOf('trailer'))
+ .match(/\/Info\s+(\d+)\s+\d+\s+R/);
+ out +=
+ `trailer\n<< /Size ${next} /Root ${sp.rootNum} 0 R` +
+ (infoM ? ` /Info ${infoM[1]} 0 R` : '') +
+ ` /Prev ${sxNum} >>\nstartxref\n${xrefAt}\n%%EOF\n`;
+ const u8 = new Uint8Array(out.length);
+ for (let i = 0; i < out.length; i++) u8[i] = out.charCodeAt(i) & 0xff;
+ return u8;
+}
+
+export async function pageHasPatternFill(originalBytes, pageIndex) {
+ try {
+ const probe = probePdfBytes(originalBytes);
+ if (!probe.pattern && !probe.objStm) return false;
+ const orig = latin(originalBytes);
+ const op = await withObjStm(originalBytes, orig, parsePdf(orig));
+ if (!op) return false;
+ const pc = await pageContent(originalBytes, orig, op, pageIndex);
+ if (pc == null) return false;
+ return shBlocks(pc.content).some((b) => b.isPattern);
+ } catch {
+ return false;
+ }
+}
+
+export const dictAt = (body, at) => {
+ const open = body.indexOf('<<', at);
+ if (open < 0) return null;
+ let depth = 0;
+ for (let i = open; i < body.length - 1; i++) {
+ if (body[i] === '<' && body[i + 1] === '<') {
+ depth++;
+ i++;
+ continue;
+ }
+ if (body[i] === '>' && body[i + 1] === '>') {
+ depth--;
+ i++;
+ if (!depth) return body.slice(open, i + 1);
+ }
+ }
+ return null;
+};
+
+export async function protectPatternArtwork(bytes) {
+ const probe = probePdfBytes(bytes);
+ if (!probe.pattern && !probe.shading && !probe.objStm) return null;
+ const src = latin(bytes);
+ const op = await withObjStm(bytes, src, parsePdf(src));
+ if (!op) return null;
+ let next = op.size;
+ const newObjs = [],
+ pageRewrites = [];
+ for (let pi = 0; pi < 4096; pi++) {
+ const pgNum = op.pageNum(pi);
+ if (pgNum < 0) break;
+ const pg = op.objBody(pgNum);
+ if (!pg) continue;
+ const cOne = pg.body.match(/\/Contents\s+(\d+)\s+\d+\s+R/);
+ if (!cOne) continue;
+ const pc = await pageContent(bytes, src, op, pi);
+ if (pc == null) continue;
+ const pats = shBlocks(pc.content).filter((b) => b.isPattern);
+ if (!pats.length) continue;
+
+ const rref = pg.body.match(/\/Resources\s+(\d+)\s+\d+\s+R/);
+ const resDict = rref
+ ? dictAt(op.objBody(parseInt(rref[1], 10))?.body ?? '', 0)
+ : dictAt(pg.body, pg.body.indexOf('/Resources'));
+ if (!resDict) continue;
+ const box = pg.body.match(/\/MediaBox\s*\[([^\]]*)\]/);
+ if (!box) continue;
+
+ let content = pc.content;
+ const added = [];
+ for (const b of [...pats].sort((x, y) => y.innerStart - x.innerStart)) {
+ const ops = content.slice(b.innerStart, b.innerEnd);
+ const num = next++;
+ const name = `ECBG${num}`;
+ newObjs.push({
+ num,
+ body:
+ `<< /Type /XObject /Subtype /Form /FormType 1 /BBox [ ${box[1].trim()} ]` +
+ ` /Resources ${resDict} /Length ${ops.length + 1} >>\nstream\n${ops}\nendstream`,
+ });
+ content =
+ content.slice(0, b.innerStart) +
+ `/${name} Do\n` +
+ content.slice(b.innerEnd);
+ added.push({ name, num });
+ }
+
+ const entries = added.map((a) => `/${a.name} ${a.num} 0 R`).join(' ');
+ let newRes;
+ const xo = resDict.match(/\/XObject\s*<<([\s\S]*?)>>/);
+ if (xo)
+ newRes = resDict.replace(xo[0], `/XObject <<${xo[1]} ${entries} >>`);
+ else newRes = resDict.replace(/^<, `<< /XObject << ${entries} >> `);
+
+ const cnum = next++;
+ newObjs.push({
+ num: cnum,
+ body: `<< /Length ${content.length + 1} >>\nstream\n${content}\nendstream`,
+ });
+ let newPage = pg.body.replace(cOne[0], `/Contents ${cnum} 0 R`);
+ newPage = rref
+ ? newPage.replace(rref[0], `/Resources ${newRes}`)
+ : newPage.replace(resDict, newRes);
+ pageRewrites.push({ num: pgNum, body: newPage });
+ }
+ if (!pageRewrites.length) return null;
+
+ let out = src;
+ if (!out.endsWith('\n')) out += '\n';
+ const offsets = new Map();
+ const emit = (num, body) => {
+ offsets.set(num, out.length);
+ out += `${num} 0 obj\n${body.trim()}\nendobj\n`;
+ };
+ for (const o of pageRewrites) emit(o.num, o.body);
+ for (const o of newObjs) emit(o.num, o.body);
+ const xrefAt = out.length;
+ const nums = [...offsets.keys()].sort((a, b) => a - b);
+ let xref = 'xref\n';
+ for (let i = 0; i < nums.length; ) {
+ let j = i;
+ while (j + 1 < nums.length && nums[j + 1] === nums[j] + 1) j++;
+ xref += `${nums[i]} ${j - i + 1}\n`;
+ for (let k = i; k <= j; k++)
+ xref += String(offsets.get(nums[k])).padStart(10, '0') + ' 00000 n \n';
+ i = j + 1;
+ }
+ out += xref;
+ const prev = src.lastIndexOf('startxref');
+ const prevNum = parseInt(src.slice(prev + 9).trim(), 10);
+ const infoM = src
+ .slice(src.lastIndexOf('trailer'))
+ .match(/\/Info\s+(\d+)\s+\d+\s+R/);
+ out +=
+ `trailer\n<< /Size ${next} /Root ${op.rootNum} 0 R` +
+ (infoM ? ` /Info ${infoM[1]} 0 R` : '') +
+ (Number.isFinite(prevNum) ? ` /Prev ${prevNum}` : '') +
+ ` >>\nstartxref\n${xrefAt}\n%%EOF\n`;
+ const u8 = new Uint8Array(out.length);
+ for (let i = 0; i < out.length; i++) u8[i] = out.charCodeAt(i) & 0xff;
+ return u8;
+}
diff --git a/src/js/editcore/streamsplice.js b/src/js/editcore/streamsplice.js
new file mode 100644
index 00000000..40d94f77
--- /dev/null
+++ b/src/js/editcore/streamsplice.js
@@ -0,0 +1,796 @@
+import { latin, parsePdf, streamData, withObjStm } from './shadingsurgery.js';
+
+function scanShowOperand(txt, at, len) {
+ const body = txt.slice(at, at + len);
+ const codes = [];
+ const slots = [];
+ let i = 0;
+ while (i < body.length) {
+ const ch = body[i];
+ if (ch === '<') {
+ const end = body.indexOf('>', i);
+ if (end < 0) return null;
+ const rawHex = body.slice(i + 1, end);
+ const hex = rawHex.replace(/\s+/g, '');
+ if (hex.length % 4 !== 0) return null;
+ if (/\s/.test(rawHex)) return null;
+ for (let k = 0; k < hex.length; k += 4) {
+ codes.push(parseInt(hex.slice(k, k + 4), 16));
+ slots.push({ at: at + i + 1 + k, len: 4, index: codes.length - 1 });
+ }
+ i = end + 1;
+ continue;
+ }
+ if (ch === '(') {
+ i++;
+ let depth = 1;
+ while (i < body.length) {
+ const c = body[i];
+ if (c === '\\') {
+ const oct = body.slice(i + 1).match(/^[0-7]{1,3}/);
+ const span = oct ? 1 + oct[0].length : 2;
+ codes.push(
+ oct
+ ? parseInt(oct[0], 8)
+ : (ESCAPES[body[i + 1]] ?? body.charCodeAt(i + 1))
+ );
+ slots.push({ at: at + i, len: span, index: codes.length - 1 });
+ i += span;
+ continue;
+ }
+ if (c === '(') depth++;
+ else if (c === ')') {
+ depth--;
+ if (!depth) {
+ i++;
+ break;
+ }
+ }
+ codes.push(body.charCodeAt(i));
+ slots.push({ at: at + i, len: 1, index: codes.length - 1 });
+ i++;
+ }
+ if (depth) return null;
+ continue;
+ }
+ i++;
+ }
+ return codes.length ? { codes, slots } : null;
+}
+const ESCAPES = { n: 10, r: 13, t: 9, b: 8, f: 12, '(': 40, ')': 41, '\\': 92 };
+
+function showOperators(txt) {
+ const re =
+ /\[(?:[^\][]|\\.)*\]\s*TJ|\((?:[^()\\]|\\.)*\)\s*Tj|<[0-9A-Fa-f\s]*>\s*Tj/g;
+ return [...txt.matchAll(re)].map((m) => ({ at: m.index, len: m[0].length }));
+}
+
+const TM_RE =
+ /(-?[\d.]+)\s+(-?[\d.]+)\s+(-?[\d.]+)\s+(-?[\d.]+)\s+(-?[\d.]+)\s+(-?[\d.]+)\s+Tm/g;
+
+function lastTmMatch(txt, at) {
+ let last = null;
+ for (const m of txt.slice(0, at).matchAll(TM_RE)) last = m;
+ return last;
+}
+function tmBefore(txt, at) {
+ const m = lastTmMatch(txt, at);
+ return m ? m.slice(1, 7).map(Number) : null;
+}
+function activeSpacing(txt, at) {
+ let tc = 0,
+ tw = 0;
+ for (const m of txt.slice(0, at).matchAll(/(-?[\d.]+)\s+(Tc|Tw)\b/g)) {
+ if (m[2] === 'Tc') tc = Number(m[1]);
+ else tw = Number(m[1]);
+ }
+ return Math.abs(tc) > 1e-6 || Math.abs(tw) > 1e-6;
+}
+
+function buildTJ(op, wide) {
+ const { after, pos, adv, size } = op;
+ if (
+ !after?.length ||
+ pos?.length !== after.length ||
+ adv?.length !== after.length
+ )
+ return null;
+ if (!(size > 0)) return null;
+ if (wide == null) wide = after.some((c) => c > 255);
+ if (!wide && after.some((c) => c > 255)) return null;
+ const hex = (c) =>
+ (wide
+ ? c.toString(16).padStart(4, '0')
+ : c.toString(16).padStart(2, '0')
+ ).toUpperCase();
+ let out = '[';
+ let run = '';
+ let pen = pos[0];
+ for (let i = 0; i < after.length; i++) {
+ if (i > 0) {
+ const kern = ((pen - pos[i]) * 1000) / size;
+ if (Math.abs(kern) > 1e-4) {
+ if (run) {
+ out += `<${run}>`;
+ run = '';
+ }
+ out += fmt(kern);
+ }
+ }
+ run += hex(after[i]);
+ pen = pos[i] + adv[i];
+ }
+ if (run) out += `<${run}>`;
+ return out + '] TJ';
+}
+
+function inlineDict(body, key) {
+ const m = body.match(new RegExp(`/${key}\\s*<<`));
+ if (!m) return null;
+ let i = m.index + m[0].length,
+ depth = 1;
+ for (let k = i; k < body.length - 1; k++) {
+ if (body[k] === '<' && body[k + 1] === '<') {
+ depth++;
+ k++;
+ } else if (body[k] === '>' && body[k + 1] === '>') {
+ depth--;
+ if (!depth) return { at: m.index, inner: body.slice(i, k), end: k + 2 };
+ k++;
+ }
+ }
+ return null;
+}
+
+const col = (rgb) => rgb.map((v) => fmt(v / 255)).join(' ');
+
+const STD14 = new Set([
+ 'Helvetica',
+ 'Helvetica-Bold',
+ 'Helvetica-Oblique',
+ 'Helvetica-BoldOblique',
+ 'Courier',
+ 'Courier-Bold',
+ 'Courier-Oblique',
+ 'Courier-BoldOblique',
+ 'Times-Roman',
+ 'Times-Bold',
+ 'Times-Italic',
+ 'Times-BoldItalic',
+ 'Symbol',
+ 'ZapfDingbats',
+]);
+
+function resolveFontNames(
+ pdf,
+ pageRec,
+ pageNum,
+ needed,
+ std14Needed,
+ startNum
+) {
+ const resNum = pdf.dictRef(pageRec.body, 'Resources');
+ let resBody = null,
+ resInline = null;
+ if (resNum >= 0) resBody = pdf.objBody(resNum)?.body;
+ else {
+ resInline = inlineDict(pageRec.body, 'Resources');
+ resBody = resInline?.inner;
+ }
+ if (resBody == null) return null;
+ const fontNum = pdf.dictRef(resBody, 'Font');
+ let fontBody = null,
+ fontInline = null;
+ if (fontNum >= 0) fontBody = pdf.objBody(fontNum)?.body;
+ else {
+ fontInline = inlineDict(resBody, 'Font');
+ fontBody = fontInline ? fontInline.inner : '';
+ }
+ if (fontBody == null) return null;
+ const nameOf = new Map();
+ const std14NameOf = new Map();
+ const extraObjs = [];
+ const used = new Set();
+ for (const m of fontBody.matchAll(/\/([^\s/<>[\]()]+)\s+(\d+)\s+0\s+R/g)) {
+ used.add(m[1]);
+ if (!nameOf.has(+m[2])) nameOf.set(+m[2], m[1]);
+ }
+ let adds = '';
+ let seq = 1;
+ for (const num of needed) {
+ if (!nameOf.has(num) && !pdf.objBody(num)) return null;
+ if (nameOf.has(num)) continue;
+ let nm;
+ do {
+ nm = `ECW${seq++}`;
+ } while (used.has(nm));
+ used.add(nm);
+ nameOf.set(num, nm);
+ adds += ` /${nm} ${num} 0 R`;
+ }
+ let free = startNum;
+ for (const base of std14Needed || []) {
+ if (!STD14.has(base)) return null;
+ let nm;
+ do {
+ nm = `ECW${seq++}`;
+ } while (used.has(nm));
+ used.add(nm);
+ const objn = free++;
+ std14NameOf.set(base, nm);
+ extraObjs.push({
+ num: objn,
+ body: `<< /Type /Font /Subtype /Type1 /BaseFont /${base} /Encoding /WinAnsiEncoding >>`,
+ });
+ adds += ` /${nm} ${objn} 0 R`;
+ }
+ const updates = [];
+ if (adds) {
+ if (fontNum >= 0) {
+ const fb = pdf.objBody(fontNum).body.trim();
+ if (!fb.endsWith('>>')) return null;
+ updates.push({ num: fontNum, body: fb.slice(0, -2) + adds + ' >>' });
+ } else if (resNum >= 0) {
+ const rb = pdf.objBody(resNum).body.trim();
+ if (!rb.endsWith('>>')) return null;
+ let nb;
+ if (fontInline) {
+ const inner2 = rb.slice(2, -2);
+ const fi = inlineDict(inner2, 'Font');
+ if (!fi) return null;
+ nb =
+ '<<' +
+ inner2.slice(0, fi.at) +
+ `/Font <<${fi.inner}${adds} >>` +
+ inner2.slice(fi.end) +
+ '>>';
+ } else {
+ nb = rb.slice(0, -2) + ` /Font <<${adds} >> >>`;
+ }
+ updates.push({ num: resNum, body: nb });
+ } else {
+ const pb = pageRec.body;
+ let nb;
+ const fi = inlineDict(pb, 'Font');
+ if (fi) {
+ nb =
+ pb.slice(0, fi.at) +
+ `/Font <<${fi.inner}${adds} >>` +
+ pb.slice(fi.end);
+ } else {
+ const ri = inlineDict(pb, 'Resources');
+ if (!ri) return null;
+ nb =
+ pb.slice(0, ri.at) +
+ `/Resources <<${ri.inner} /Font <<${adds} >> >>` +
+ pb.slice(ri.end);
+ }
+ updates.push({ num: pageNum, body: nb.trim() });
+ }
+ }
+ return { nameOf, std14NameOf, updates, extraObjs, nextFree: free };
+}
+
+function danglingCtm(txt) {
+ let depth = 0;
+ let m = [1, 0, 0, 1, 0, 0];
+ const re =
+ /(?:^|[\s>\]])((?:-?[\d.]+\s+){6})cm\b|(?:^|[\s>\])])(q)\b|(?:^|[\s>\])])(Q)\b/g;
+ for (const t of txt.matchAll(re)) {
+ if (t[2]) depth++;
+ else if (t[3]) depth = Math.max(0, depth - 1);
+ else if (t[1] && depth === 0) {
+ const v = t[1].trim().split(/\s+/).map(Number);
+ if (v.length === 6 && v.every((x) => Number.isFinite(x))) {
+ const [a, b, c, d, e, f] = v,
+ [A, B, C, D, E, F] = m;
+ m = [
+ a * A + b * C,
+ a * B + b * D,
+ c * A + d * C,
+ c * B + d * D,
+ e * A + f * C + E,
+ e * B + f * D + F,
+ ];
+ }
+ }
+ }
+ return m;
+}
+function invertMatrix(m) {
+ const [a, b, c, d, e, f] = m;
+ const det = a * d - b * c;
+ if (Math.abs(det) < 1e-9) return null;
+ return [
+ d / det,
+ -b / det,
+ -c / det,
+ a / det,
+ (c * f - d * e) / det,
+ (b * e - a * f) / det,
+ ];
+}
+
+function buildDrawBlock(drawOps, nameOf, std14NameOf, ctm) {
+ let out = '';
+ for (const op of drawOps) {
+ if (op.kind === 'rectdraw') {
+ out += `${col(op.rgb)} rg\n${fmt(op.x)} ${fmt(op.y)} ${fmt(op.w)} ${fmt(op.h)} re f\n`;
+ continue;
+ }
+ const name = op.std14 ? std14NameOf.get(op.std14) : nameOf.get(op.font);
+ if (!name) return null;
+ const tj = buildTJ(
+ { after: op.codes, pos: op.pos, adv: op.adv, size: op.size },
+ !!op.wide
+ );
+ if (tj == null) return null;
+ out += 'BT\n' + `${col(op.rgb)} rg\n`;
+ if (op.mode) out += `${op.mode} Tr\n`;
+ if (op.mode === 1 || op.mode === 2 || op.mode === 5 || op.mode === 6)
+ out += `${col(op.srgb)} RG\n${fmt(op.sw)} w\n`;
+ out +=
+ `/${name} ${fmt(op.size)} Tf\n` +
+ op.tm.map(fmt).join(' ') +
+ ' Tm\n' +
+ tj +
+ '\nET\n';
+ }
+ if (!out) return '';
+ let pre = '';
+ if (
+ ctm &&
+ (Math.abs(ctm[0] - 1) > 1e-6 ||
+ Math.abs(ctm[1]) > 1e-6 ||
+ Math.abs(ctm[2]) > 1e-6 ||
+ Math.abs(ctm[3] - 1) > 1e-6 ||
+ Math.abs(ctm[4]) > 1e-6 ||
+ Math.abs(ctm[5]) > 1e-6)
+ ) {
+ const inv = invertMatrix(ctm);
+ if (!inv) return null;
+ pre = inv.map(fmt).join(' ') + ' cm\n';
+ }
+ return 'q\n' + pre + out + 'Q\n';
+}
+
+function fmt(v) {
+ let s = v.toFixed(5).replace(/0+$/, '').replace(/\.$/, '');
+ return s === '-0' ? '0' : s;
+}
+
+function locate(txt, want, tm) {
+ const hits = [];
+ for (const op of showOperators(txt)) {
+ const s = scanShowOperand(txt, op.at, op.len);
+ if (!s || s.codes.length !== want.length) continue;
+ if (!s.codes.every((c, i) => c === want[i])) continue;
+ hits.push({ op, scan: s, tm: tmBefore(txt, op.at) });
+ }
+ if (!hits.length) return null;
+ if (hits.length === 1) return hits[0];
+ const near = hits.filter(
+ (h) =>
+ h.tm &&
+ tm &&
+ Math.abs(h.tm[4] - tm[4]) < 0.01 &&
+ Math.abs(h.tm[5] - tm[5]) < 0.01
+ );
+ return near.length === 1 ? near[0] : null;
+}
+
+async function findHolderStream(bytes, src, pdf, pageRec, codes) {
+ const resNum = pdf.dictRef(pageRec.body, 'Resources');
+ const resBody =
+ resNum >= 0
+ ? pdf.objBody(resNum)?.body
+ : pageRec.body.match(/\/Resources\s*<<([\s\S]*?)>>/)?.[1];
+ if (!resBody) return null;
+ const xoNum = pdf.dictRef(resBody, 'XObject');
+ const xoBody =
+ xoNum >= 0
+ ? pdf.objBody(xoNum)?.body
+ : resBody.match(/\/XObject\s*<<([\s\S]*?)>>/)?.[1];
+ if (!xoBody) return null;
+ for (const m of xoBody.matchAll(/\/([^\s/<>[\]()]+)\s+(\d+)\s+0\s+R/g)) {
+ const num = +m[2];
+ const rec = pdf.objBody(num);
+ if (!rec || !/\/Subtype\s*\/Form/.test(rec.body)) continue;
+ const data = await streamData(bytes, src, { num, ...rec });
+ if (!data) continue;
+ const text = typeof data === 'string' ? data : latin(data);
+ if (!locate(text, codes, null)) continue;
+ const refs = [...src.matchAll(new RegExp(`\\b${num}\\s+0\\s+R\\b`, 'g'))]
+ .length;
+ let pagesUsing = 0;
+ if (resNum >= 0) {
+ for (const pm of src.matchAll(/\/Type\s*\/Page\b/g)) {
+ const start = src.lastIndexOf('obj', pm.index);
+ const end = src.indexOf('endobj', pm.index);
+ if (start < 0 || end < 0) continue;
+ if (
+ new RegExp(`/Resources\\s+${resNum}\\s+0\\s+R`).test(
+ src.slice(start, end)
+ )
+ )
+ pagesUsing++;
+ }
+ }
+ return {
+ num,
+ rec,
+ text,
+ name: m[1],
+ xoNum,
+ xoBody,
+ resNum,
+ shared: refs > 1 || pagesUsing > 1,
+ };
+ }
+ return null;
+}
+
+export async function applyPlan(originalBytes, pageIndex, plan) {
+ if (
+ !plan ||
+ !plan.ops?.length ||
+ (plan.kind !== 'delete' && plan.kind !== 'splice' && plan.kind !== 'write')
+ )
+ return null;
+ const src = latin(originalBytes);
+ if (/\/Encrypt\s+\d+\s+\d+\s+R|\/Encrypt\s*< o.kind === 'blank' || o.kind === 'draw' || o.kind === 'rectdraw'
+ );
+ let cNum,
+ cRec,
+ cow = null,
+ txt;
+ let emptyMembers = [];
+ if (cm) {
+ cNum = +cm[1];
+ cRec = pdf.objBody(cNum);
+ if (!cRec) return null;
+ const raw = await streamData(originalBytes, src, { num: cNum, ...cRec });
+ if (!raw) return null;
+ txt = typeof raw === 'string' ? raw : latin(raw);
+ } else if (cArr && isWritePlan) {
+ const refs = [...cArr[1].matchAll(/(\d+)\s+\d+\s+R/g)].map((m) => +m[1]);
+ if (!refs.length) return null;
+ let concat = '';
+ for (const n of refs) {
+ const rec = pdf.objBody(n);
+ if (!rec) return null;
+ const raw = await streamData(originalBytes, src, { num: n, ...rec });
+ if (raw == null) return null;
+ concat +=
+ (concat ? '\n' : '') + (typeof raw === 'string' ? raw : latin(raw));
+ }
+ cNum = refs[0];
+ cRec = pdf.objBody(cNum);
+ txt = concat;
+ emptyMembers = refs.slice(1);
+ } else {
+ return null;
+ }
+
+ const firstCodes = plan.ops.find((o) => o.codes)?.codes;
+ const isWrite = plan.ops.some(
+ (o) => o.kind === 'blank' || o.kind === 'draw' || o.kind === 'rectdraw'
+ );
+ if (!isWrite && firstCodes && !locate(txt, firstCodes, plan.ops[0].tm)) {
+ const holder = await findHolderStream(
+ originalBytes,
+ src,
+ pdf,
+ pageRec,
+ firstCodes
+ );
+ if (!holder) return null;
+ ({ num: cNum, rec: cRec, text: txt } = holder);
+ if (holder.shared) cow = { holder, pageNum, pageRec };
+ }
+
+ const edits = [];
+ const drawOps = [];
+ const blankOps = plan.ops.filter((o) => o.kind === 'blank');
+ if (blankOps.length) {
+ const all = showOperators(txt).map((o) => ({
+ ...o,
+ scan: scanShowOperand(txt, o.at, o.len),
+ }));
+ const matches = (o, codes) =>
+ o.scan &&
+ o.scan.codes.length === codes.length &&
+ o.scan.codes.every((c, i) => c === codes[i]);
+ const chainFrom = (start) => {
+ const picks = [];
+ let k = start;
+ for (const op of blankOps) {
+ while (k < all.length && !matches(all[k], op.codes)) k++;
+ if (k >= all.length) return null;
+ picks.push(all[k]);
+ k++;
+ }
+ return picks;
+ };
+ let chain = null;
+ for (let a = 0; a < all.length; a++) {
+ if (!matches(all[a], blankOps[0].codes)) continue;
+ const c = chainFrom(a);
+ if (c) {
+ if (chain) {
+ chain = null;
+ break;
+ }
+ chain = c;
+ for (let a2 = a + 1; a2 < all.length; a2++) {
+ if (!matches(all[a2], blankOps[0].codes)) continue;
+ const c2 = chainFrom(a2);
+ if (c2 && c2[0].at !== c[0].at && c2[0].at > c[c.length - 1].at) {
+ chain = null;
+ }
+ break;
+ }
+ break;
+ }
+ }
+ if (!chain) return null;
+ for (const pick of chain)
+ edits.push({ at: pick.at, len: pick.len, text: '[] TJ' });
+ }
+ for (const op of plan.ops) {
+ if (op.kind === 'blank') continue;
+ if (op.kind === 'draw' || op.kind === 'rectdraw') {
+ drawOps.push(op);
+ continue;
+ }
+ const hit = locate(txt, op.codes, op.tm);
+ if (!hit) return null;
+ if (op.kind === 'rebuild') {
+ if (activeSpacing(txt, hit.op.at)) return null;
+ const wide = hit.scan.slots.some((s2) => s2.len === 4);
+ const arr = buildTJ(op, wide);
+ if (arr == null) return null;
+ edits.push({ at: hit.op.at, len: hit.op.len, text: arr });
+ if (op.dx || op.dy) {
+ const m = lastTmMatch(txt, hit.op.at);
+ if (!m) return null;
+ const e = Number(m[5]) + (op.dx || 0);
+ const f = Number(m[6]) + (op.dy || 0);
+ edits.push({
+ at: m.index,
+ len: m[0].length,
+ text: `${m[1]} ${m[2]} ${m[3]} ${m[4]} ${fmt(e)} ${fmt(f)} Tm`,
+ });
+ }
+ continue;
+ }
+ if (op.kind === 'tm') {
+ const m = lastTmMatch(txt, hit.op.at);
+ if (!m) return null;
+ const e = Number(m[5]) + (op.dx || 0);
+ const f = Number(m[6]) + (op.dy || 0);
+ const text = `${m[1]} ${m[2]} ${m[3]} ${m[4]} ${fmt(e)} ${fmt(f)} Tm`;
+ edits.push({ at: m.index, len: m[0].length, text });
+ continue;
+ }
+ for (const idx of op.drop) {
+ const slot = hit.scan.slots[idx];
+ if (!slot) return null;
+ edits.push({ at: slot.at, len: slot.len, text: '' });
+ }
+ }
+ if (!edits.length && !drawOps.length) return null;
+ const sorted = [...edits].sort((a, b) => a.at - b.at);
+ for (let i = 1; i < sorted.length; i++)
+ if (sorted[i].at < sorted[i - 1].at + sorted[i - 1].len) return null;
+ sorted.reverse();
+ for (const e of sorted)
+ txt = txt.slice(0, e.at) + e.text + txt.slice(e.at + e.len);
+
+ let resourceUpdates = [];
+ let extraFontObjs = [];
+ if (drawOps.length) {
+ if (cow) return null;
+ const needed = new Set(
+ drawOps.filter((o) => o.kind === 'draw' && !o.std14).map((o) => o.font)
+ );
+ const std14Needed = new Set(
+ drawOps.filter((o) => o.std14).map((o) => o.std14)
+ );
+ const res = resolveFontNames(
+ pdf,
+ pageRec,
+ pageNum,
+ needed,
+ std14Needed,
+ pdf.size
+ );
+ if (!res) return null;
+ const block = buildDrawBlock(
+ drawOps,
+ res.nameOf,
+ res.std14NameOf,
+ danglingCtm(txt)
+ );
+ if (block == null) return null;
+ txt += '\n' + block;
+ resourceUpdates = res.updates;
+ extraFontObjs = res.extraObjs || [];
+ }
+
+ for (const n of emptyMembers) {
+ const rec = pdf.objBody(n);
+ if (!rec) return null;
+ const dict = rec.body
+ .slice(0, rec.body.indexOf('stream'))
+ .replace(/\/Filter\s*\/\w+/g, '')
+ .replace(/\/Filter\s*\[[^\]]*\]/g, '')
+ .replace(/\/DecodeParms\s*<<[^>]*>>/g, '')
+ .replace(/\/Length\s+\d+(\s+\d+\s+R)?/g, '')
+ .replace(/>>\s*$/, '')
+ .replace(/^\s*<, '');
+ extraFontObjs.push({ num: n, stream: '', dict });
+ }
+
+ return rewriteStream(originalBytes, src, pdf, cNum, cRec, txt, cow, [
+ ...resourceUpdates,
+ ...extraFontObjs,
+ ]);
+}
+
+export const spliceDeletion = applyPlan;
+
+const bin = (s) => Uint8Array.from(s, (c) => c.charCodeAt(0) & 0xff);
+
+function xrefStyle(src) {
+ const i = src.lastIndexOf('startxref');
+ if (i < 0) return 'classic';
+ const m = src.slice(i).match(/startxref\s+(\d+)/);
+ if (!m) return 'classic';
+ const at = +m[1];
+ return /^\s*xref/.test(src.slice(at, at + 8)) ? 'classic' : 'stream';
+}
+
+function rewriteStream(originalBytes, src, pdf, num, rec, text, cow, moreObjs) {
+ let targetNum = num;
+ const extra = [];
+ let nextFree = Math.max(pdf.size, num + 1);
+ for (const e of moreObjs || [])
+ nextFree = Math.max(nextFree, (e.num || 0) + 1);
+ if (cow) {
+ const h = cow.holder;
+ if (h.xoNum < 0 || h.resNum < 0) return null;
+ targetNum = nextFree++;
+ const newXo = nextFree++;
+ const newRes = nextFree++;
+
+ const xo = h.xoBody.replace(
+ new RegExp(`(/${h.name}\\s+)\\d+(\\s+0\\s+R)`),
+ `$1${targetNum}$2`
+ );
+ if (xo === h.xoBody) return null;
+ extra.push({ num: newXo, body: `<<${xo}>>` });
+
+ const resRec = pdf.objBody(h.resNum);
+ if (!resRec) return null;
+ const res = resRec.body.replace(
+ new RegExp(`(/XObject\\s+)${h.xoNum}(\\s+0\\s+R)`),
+ `$1${newXo}$2`
+ );
+ if (res === resRec.body) return null;
+ extra.push({ num: newRes, body: res.trim() });
+
+ const pageBody = cow.pageRec.body.replace(
+ new RegExp(`(/Resources\\s+)${h.resNum}(\\s+0\\s+R)`),
+ `$1${newRes}$2`
+ );
+ if (pageBody === cow.pageRec.body) return null;
+ extra.push({ num: cow.pageNum, body: pageBody.trim() });
+ }
+ for (const e of moreObjs || []) extra.push(e);
+
+ const out = bin(text);
+ const dict = rec.body.slice(0, rec.body.indexOf('stream'));
+ let nd = dict
+ .replace(/\/Filter\s*\/\w+/g, '')
+ .replace(/\/Filter\s*\[[^\]]*\]/g, '')
+ .replace(/\/DecodeParms\s*<<[^>]*>>/g, '')
+ .replace(/\/Length\s+\d+(\s+\d+\s+R)?/g, '');
+ nd = nd.replace(/>>\s*$/, '').replace(/^\s*<, '');
+
+ const bytes = [];
+ const push = (s2) => {
+ for (const ch of bin(s2)) bytes.push(ch);
+ };
+ const base = originalBytes.length;
+ const offsets = new Map();
+
+ push('\n');
+ offsets.set(targetNum, base + bytes.length);
+ push(`${targetNum} 0 obj\n<<${nd} /Length ${out.length}>>\nstream\n`);
+ for (const b2 of out) bytes.push(b2);
+ push('\nendstream\nendobj\n');
+ for (const e of extra) {
+ offsets.set(e.num, base + bytes.length);
+ if (e.stream !== undefined) {
+ const sb = bin(e.stream);
+ push(
+ `${e.num} 0 obj\n<<${e.dict || ''} /Length ${sb.length}>>\nstream\n`
+ );
+ for (const b2 of sb) bytes.push(b2);
+ push('\nendstream\nendobj\n');
+ } else {
+ push(`${e.num} 0 obj\n${e.body}\nendobj\n`);
+ }
+ }
+
+ const prev = prevStartXref(src);
+ const style = xrefStyle(src);
+ if (style === 'classic') {
+ const xrefAt = base + bytes.length;
+ const nums = [...offsets.keys()].sort((x, y) => x - y);
+ push('xref\n0 1\n0000000000 65535 f \n');
+ for (const n of nums)
+ push(
+ `${n} 1\n` + String(offsets.get(n)).padStart(10, '0') + ' 00000 n \n'
+ );
+ const size = Math.max(pdf.size, Math.max(...nums) + 1);
+ push(
+ `trailer\n<< /Size ${size} /Root ${pdf.rootNum} 0 R /Prev ${prev} >>\n`
+ );
+ push(`startxref\n${xrefAt}\n%%EOF\n`);
+ } else {
+ const xrefNum = nextFree++;
+ const xrefAt = base + bytes.length;
+ offsets.set(xrefNum, xrefAt);
+ const nums = [...offsets.keys()].sort((x, y) => x - y);
+ const size = Math.max(pdf.size, Math.max(...nums) + 1);
+ let index = '';
+ const rows = [];
+ for (const n of nums) {
+ index += `${n} 1 `;
+ const off = offsets.get(n);
+ rows.push(
+ 1,
+ (off >>> 24) & 0xff,
+ (off >>> 16) & 0xff,
+ (off >>> 8) & 0xff,
+ off & 0xff,
+ 0,
+ 0
+ );
+ }
+ push(
+ `${xrefNum} 0 obj\n<< /Type /XRef /Size ${size} /Index [ ${index}] ` +
+ `/W [1 4 2] /Root ${pdf.rootNum} 0 R /Prev ${prev} ` +
+ `/Length ${rows.length} >>\nstream\n`
+ );
+ for (const b2 of rows) bytes.push(b2);
+ push('\nendstream\nendobj\n');
+ push(`startxref\n${xrefAt}\n%%EOF\n`);
+ }
+
+ const res2 = new Uint8Array(base + bytes.length);
+ res2.set(originalBytes, 0);
+ res2.set(Uint8Array.from(bytes), base);
+ return res2;
+}
+
+function prevStartXref(src) {
+ const i = src.lastIndexOf('startxref');
+ if (i < 0) return 0;
+ const m = src.slice(i).match(/startxref\s+(\d+)/);
+ return m ? +m[1] : 0;
+}
diff --git a/src/js/editcore/tagsurgery.js b/src/js/editcore/tagsurgery.js
new file mode 100644
index 00000000..64fbcc86
--- /dev/null
+++ b/src/js/editcore/tagsurgery.js
@@ -0,0 +1,253 @@
+const latin = (u8) => {
+ let s = '';
+ const CH = 0x8000;
+ for (let i = 0; i < u8.length; i += CH) {
+ s += String.fromCharCode.apply(
+ null,
+ u8.subarray(i, Math.min(i + CH, u8.length))
+ );
+ }
+ return s;
+};
+
+export function applyTagSurgery(
+ bytes,
+ { pageIndex = 0, newMarks = [], altText = [] } = {}
+) {
+ if (!newMarks.length && !altText.length) return bytes;
+ const src = latin(bytes);
+
+ const sx = src.lastIndexOf('startxref');
+ if (sx < 0) return bytes;
+ const sxNum = parseInt(src.slice(sx + 9).trim(), 10);
+ if (!Number.isFinite(sxNum) || src.slice(sxNum, sxNum + 4) !== 'xref') {
+ console.warn('[tags] non-classic xref — surgery skipped');
+ return bytes;
+ }
+ const trailerAt = src.lastIndexOf('trailer');
+ if (trailerAt < 0) return bytes;
+ const trailer = src.slice(trailerAt, sx);
+ const tref = (key) => {
+ const m = trailer.match(new RegExp('/' + key + '\\s+(\\d+)\\s+\\d+\\s+R'));
+ return m ? parseInt(m[1], 10) : -1;
+ };
+ const tint = (key) => {
+ const m = trailer.match(new RegExp('/' + key + '\\s+(\\d+)'));
+ return m ? parseInt(m[1], 10) : -1;
+ };
+ const rootNum = tref('Root');
+ const size = tint('Size');
+ if (rootNum < 0 || size < 0) return bytes;
+
+ const objBody = (num) => {
+ let at = -1;
+ for (const m of src.matchAll(
+ new RegExp('(?:^|[^0-9])' + num + '\\s+0\\s+obj\\b', 'g')
+ )) {
+ at = m.index;
+ }
+ if (at < 0) return null;
+ const start = src.indexOf('obj', at) + 3;
+ const end = src.indexOf('endobj', start);
+ if (end < 0) return null;
+ return { start, end, body: src.slice(start, end) };
+ };
+ const dictRef = (body, key) => {
+ const m = body.match(new RegExp('/' + key + '\\s+(\\d+)\\s+\\d+\\s+R'));
+ return m ? parseInt(m[1], 10) : -1;
+ };
+ const dictInt = (body, key) => {
+ const m = body.match(new RegExp('/' + key + '\\s+(-?\\d+)'));
+ return m ? parseInt(m[1], 10) : -1;
+ };
+
+ const root = objBody(rootNum);
+ if (!root) return bytes;
+ const strNum = dictRef(root.body, 'StructTreeRoot');
+ if (strNum < 0) return bytes;
+ const str = objBody(strNum);
+ if (!str) return bytes;
+ const parentTreeNum = dictRef(str.body, 'ParentTree');
+
+ const pagesNum = dictRef(root.body, 'Pages');
+ let pageNum = -1;
+ {
+ let remaining = pageIndex;
+ const walk = (num) => {
+ const o = objBody(num);
+ if (!o) return -1;
+ if (/\/Type\s*\/Page\b(?!s)/.test(o.body)) {
+ if (remaining === 0) return num;
+ remaining--;
+ return -1;
+ }
+ const kids = o.body.match(/\/Kids\s*\[([^\]]*)\]/);
+ if (!kids) return -1;
+ for (const r of kids[1].matchAll(/(\d+)\s+\d+\s+R/g)) {
+ const hit = walk(parseInt(r[1], 10));
+ if (hit >= 0) return hit;
+ }
+ return -1;
+ };
+ pageNum = walk(pagesNum);
+ }
+ if (pageNum < 0) return bytes;
+ const page = objBody(pageNum);
+ const structParents = page ? dictInt(page.body, 'StructParents') : -1;
+ if (structParents < 0) {
+ console.warn('[tags] page has no /StructParents — surgery skipped');
+ return bytes;
+ }
+
+ let ptArrayNum = -1;
+ {
+ const visit = (num) => {
+ const o = objBody(num);
+ if (!o) return;
+ const kids = o.body.match(/\/Kids\s*\[([^\]]*)\]/);
+ if (kids) {
+ for (const r of kids[1].matchAll(/(\d+)\s+\d+\s+R/g))
+ visit(parseInt(r[1], 10));
+ }
+ const nums = o.body.match(/\/Nums\s*\[([\s\S]*?)\]/);
+ if (!nums) return;
+ for (const t of nums[1].matchAll(/(\d+)\s+(\d+)\s+\d+\s+R/g)) {
+ if (parseInt(t[1], 10) === structParents)
+ ptArrayNum = parseInt(t[2], 10);
+ }
+ };
+ if (parentTreeNum >= 0) visit(parentTreeNum);
+ else {
+ const nums = str.body.match(
+ /\/ParentTree\s*<<[\s\S]*?\/Nums\s*\[([\s\S]*?)\]/
+ );
+ if (nums) {
+ for (const t of nums[1].matchAll(/(\d+)\s+(\d+)\s+\d+\s+R/g)) {
+ if (parseInt(t[1], 10) === structParents)
+ ptArrayNum = parseInt(t[2], 10);
+ }
+ }
+ }
+ }
+ if (ptArrayNum < 0) {
+ console.warn('[tags] ParentTree entry for the page not found — skipped');
+ return bytes;
+ }
+ const ptArr = objBody(ptArrayNum);
+ if (!ptArr) return bytes;
+ const arrM = ptArr.body.match(/\[([\s\S]*?)\]/);
+ if (!arrM) return bytes;
+ const items = [];
+ for (const t of arrM[1].matchAll(/(\d+\s+\d+\s+R|null)/g))
+ items.push(t[1].replace(/\s+/g, ' '));
+
+ let parentNum = strNum;
+ {
+ const kRef = str.body.match(/\/K\s+(\d+)\s+\d+\s+R/);
+ const kArr = str.body.match(/\/K\s*\[\s*(\d+)\s+\d+\s+R/);
+ const cand = kRef
+ ? parseInt(kRef[1], 10)
+ : kArr
+ ? parseInt(kArr[1], 10)
+ : -1;
+ if (cand >= 0) {
+ const co = objBody(cand);
+ const sName = co && co.body.match(/\/S\s*\/(\w+)/);
+ if (sName && /^(Document|Part|Art|Sect|Div)$/.test(sName[1])) {
+ parentNum = cand;
+ }
+ }
+ }
+ const parent = objBody(parentNum);
+ if (!parent) return bytes;
+
+ let next = size;
+ const newObjs = [];
+ const updated = new Map();
+ const esc = (t) =>
+ t.replace(/\\/g, '\\\\').replace(/\(/g, '\\(').replace(/\)/g, '\\)');
+
+ const uniq = [...new Map(newMarks.map((m) => [m.mcid, m])).values()].sort(
+ (a, b) => a.mcid - b.mcid
+ );
+ const addedRefs = [];
+ for (const mk of uniq) {
+ const alt = altText.find((a) => a.mcid === mk.mcid);
+ const num = next++;
+ newObjs.push({
+ num,
+ body:
+ `<< /Type /StructElem /S /${mk.type || 'P'} /P ${parentNum} 0 R ` +
+ `/Pg ${pageNum} 0 R /K ${mk.mcid}` +
+ (alt ? ` /Alt (${esc(alt.text)})` : '') +
+ ` >>`,
+ });
+ addedRefs.push(`${num} 0 R`);
+ while (items.length <= mk.mcid) items.push('null');
+ items[mk.mcid] = `${num} 0 R`;
+ }
+
+ for (const a of altText) {
+ if (uniq.some((m) => m.mcid === a.mcid)) continue;
+ const entry = items[a.mcid];
+ const m = entry && entry.match(/(\d+)\s+0\s+R/);
+ if (!m) continue;
+ const elemNum = parseInt(m[1], 10);
+ const elem = objBody(elemNum);
+ if (!elem) continue;
+ let body = elem.body;
+ body = /\/Alt\s*\([^)]*\)/.test(body)
+ ? body.replace(/\/Alt\s*\([^)]*\)/, `/Alt (${esc(a.text)})`)
+ : body.replace(/>>\s*$/, `/Alt (${esc(a.text)}) >>`);
+ updated.set(elemNum, body);
+ }
+
+ if (addedRefs.length) {
+ let pb = parent.body;
+ const kArr = pb.match(/\/K\s*\[([\s\S]*?)\]/);
+ if (kArr) {
+ pb = pb.replace(kArr[0], `/K [${kArr[1]} ${addedRefs.join(' ')}]`);
+ } else {
+ const kOne = pb.match(/\/K\s+((?:\d+\s+\d+\s+R)|\d+)/);
+ if (!kOne) return bytes;
+ pb = pb.replace(kOne[0], `/K [${kOne[1]} ${addedRefs.join(' ')}]`);
+ }
+ updated.set(parentNum, pb);
+ updated.set(ptArrayNum, `[ ${items.join(' ')} ]`);
+ }
+
+ if (!newObjs.length && !updated.size) return bytes;
+
+ let out = src;
+ if (!out.endsWith('\n')) out += '\n';
+ const offsets = new Map();
+ const emit = (num, body) => {
+ offsets.set(num, out.length);
+ out += `${num} 0 obj\n${body.trim()}\nendobj\n`;
+ };
+ for (const [num, body] of updated) emit(num, body);
+ for (const o of newObjs) emit(o.num, o.body);
+
+ const xrefAt = out.length;
+ const nums = [...offsets.keys()].sort((a, b) => a - b);
+ let xref = 'xref\n';
+ for (let i = 0; i < nums.length; ) {
+ let j = i;
+ while (j + 1 < nums.length && nums[j + 1] === nums[j] + 1) j++;
+ xref += `${nums[i]} ${j - i + 1}\n`;
+ for (let k = i; k <= j; k++) {
+ xref += String(offsets.get(nums[k])).padStart(10, '0') + ' 00000 n \n';
+ }
+ i = j + 1;
+ }
+ out += xref;
+ const infoNum = tref('Info');
+ out +=
+ `trailer\n<< /Size ${next} /Root ${rootNum} 0 R` +
+ (infoNum >= 0 ? ` /Info ${infoNum} 0 R` : '') +
+ ` /Prev ${sxNum} >>\nstartxref\n${xrefAt}\n%%EOF\n`;
+
+ const u8 = new Uint8Array(out.length);
+ for (let i = 0; i < out.length; i++) u8[i] = out.charCodeAt(i) & 0xff;
+ return u8;
+}
diff --git a/src/js/editcore/type3surgery.js b/src/js/editcore/type3surgery.js
new file mode 100644
index 00000000..2ce5d69b
--- /dev/null
+++ b/src/js/editcore/type3surgery.js
@@ -0,0 +1,779 @@
+import {
+ latin,
+ parsePdf,
+ streamData,
+ withObjStm,
+ dictAt,
+ probePdfBytes,
+} from './shadingsurgery.js';
+
+const mul = (m, n) => [
+ m[0] * n[0] + m[1] * n[2],
+ m[0] * n[1] + m[1] * n[3],
+ m[2] * n[0] + m[3] * n[2],
+ m[2] * n[1] + m[3] * n[3],
+ m[4] * n[0] + m[5] * n[2] + n[4],
+ m[4] * n[1] + m[5] * n[3] + n[5],
+];
+const I = [1, 0, 0, 1, 0, 0];
+
+const TOKEN_RE =
+ /\((?:\\.|[^\\)])*\)|<<[\s\S]*?>>|<[0-9A-Fa-f\s]*>|\[(?:\((?:\\.|[^\\)])*\)|<[0-9A-Fa-f\s]*>|[^\]])*\]|\/[^\s/<>()[\]{}%]*|[^\s/<>()[\]{}%]+/g;
+
+const OPERATOR = /^[A-Za-z'"][A-Za-z0-9*'"]*$/;
+
+function analyzeContent(content, fontIsT3) {
+ const blocks = [];
+ const tjs = [];
+ const stack = [];
+ let ctm = I;
+ let fill = null,
+ strokeC = null,
+ gsOp = null;
+ let tf = null,
+ tfSize = 0,
+ tc = null,
+ tw = null,
+ tz = null,
+ tl = null,
+ ts = null,
+ trm = null;
+ let btEntry = null;
+ let tm = I,
+ tlm = I;
+ let blk = null;
+ let pend = [];
+ const hexCodes = (tok) => {
+ const out = [];
+ if (tok[0] === '<') {
+ const h = tok.slice(1, -1).replace(/\s+/g, '');
+ for (let i = 0; i < h.length; i += 2) {
+ const b = h.slice(i, i + 2);
+ out.push(parseInt(b.length === 2 ? b : b + '0', 16));
+ }
+ } else if (tok[0] === '(') {
+ const s = tok.slice(1, -1);
+ for (let i = 0; i < s.length; i++) {
+ let c = s[i];
+ if (c === '\\') {
+ const n = s[i + 1];
+ if (n >= '0' && n <= '7') {
+ let oct = '';
+ while (oct.length < 3 && s[i + 1] >= '0' && s[i + 1] <= '7')
+ oct += s[++i];
+ out.push(parseInt(oct, 8));
+ continue;
+ }
+ i++;
+ c = { n: '\n', r: '\r', t: '\t', b: '\b', f: '\f' }[n] ?? n;
+ }
+ out.push(c.charCodeAt(0) & 0xff);
+ }
+ }
+ return out;
+ };
+ const show = (tok) => {
+ if (tok == null) return;
+ let codes = [];
+ if (tok[0] === '[') {
+ for (const el of tok
+ .slice(1, -1)
+ .matchAll(/\((?:\\.|[^\\)])*\)|<[0-9A-Fa-f\s]*>/g))
+ codes = codes.concat(hexCodes(el[0]));
+ } else codes = hexCodes(tok);
+ const dev = mul(tm, ctm);
+ tjs.push({
+ e: dev[4],
+ f: dev[5],
+ font: tf,
+ size: tfSize,
+ codes,
+ block: blocks.length,
+ });
+ if (blk) {
+ blk.shows.push(tjs.length - 1);
+ if (tf) blk.fonts.add(tf);
+ else blk.selfContained = false;
+ }
+ };
+ for (const m of content.matchAll(TOKEN_RE)) {
+ const t = m[0],
+ at = m.index;
+ if (!OPERATOR.test(t) || /^[\d.]/.test(t)) {
+ pend.push(t);
+ continue;
+ }
+ const nums = () => pend.map(Number);
+ switch (t) {
+ case 'q':
+ stack.push({
+ ctm,
+ fill,
+ strokeC,
+ gsOp,
+ tf,
+ tfSize,
+ tc,
+ tw,
+ tz,
+ tl,
+ ts,
+ trm,
+ });
+ break;
+ case 'Q': {
+ const s = stack.pop();
+ if (s)
+ ({ ctm, fill, strokeC, gsOp, tf, tfSize, tc, tw, tz, tl, ts, trm } =
+ s);
+ break;
+ }
+ case 'cm': {
+ const n = nums();
+ if (n.length === 6) ctm = mul(n, ctm);
+ break;
+ }
+ case 'gs':
+ gsOp = pend[0] + ' gs';
+ break;
+ case 'g':
+ case 'rg':
+ case 'k':
+ fill = pend.join(' ') + ' ' + t;
+ break;
+ case 'G':
+ case 'RG':
+ case 'K':
+ strokeC = pend.join(' ') + ' ' + t;
+ break;
+ case 'sc':
+ case 'scn':
+ fill = pend.join(' ') + ' ' + t;
+ break;
+ case 'SC':
+ case 'SCN':
+ strokeC = pend.join(' ') + ' ' + t;
+ break;
+ case 'BT':
+ tm = I;
+ tlm = I;
+ btEntry = { fill, strokeC, gsOp, tf, tfSize, tc, tw, tz, tl, ts, trm };
+ blk = {
+ start: at,
+ end: -1,
+ fonts: new Set(),
+ shows: [],
+ selfContained: true,
+ entry: btEntry,
+ };
+ break;
+ case 'ET':
+ if (blk) {
+ blk.end = at + 2;
+ blocks.push(blk);
+ }
+ blk = null;
+ break;
+ case 'Tf':
+ tf = pend[0] ? pend[0].slice(1) : null;
+ tfSize = Number(pend[1]) || 0;
+ break;
+ case 'Tm': {
+ const n = nums();
+ if (n.length === 6) {
+ tm = n;
+ tlm = n;
+ }
+ break;
+ }
+ case 'Td': {
+ const n = nums();
+ tlm = mul([1, 0, 0, 1, n[0] || 0, n[1] || 0], tlm);
+ tm = tlm;
+ break;
+ }
+ case 'TD': {
+ const n = nums();
+ tl = -(n[1] || 0);
+ tlm = mul([1, 0, 0, 1, n[0] || 0, n[1] || 0], tlm);
+ tm = tlm;
+ break;
+ }
+ case 'T*':
+ tlm = mul([1, 0, 0, 1, 0, -(tl || 0)], tlm);
+ tm = tlm;
+ break;
+ case 'TL':
+ tl = Number(pend[0]) || 0;
+ break;
+ case 'Tc':
+ tc = pend[0] + ' Tc';
+ break;
+ case 'Tw':
+ tw = pend[0] + ' Tw';
+ break;
+ case 'Tz':
+ tz = pend[0] + ' Tz';
+ break;
+ case 'Ts':
+ ts = pend[0] + ' Ts';
+ break;
+ case 'Tr':
+ trm = pend[0] + ' Tr';
+ break;
+ case 'Tj':
+ show(pend[pend.length - 1]);
+ break;
+ case 'TJ':
+ show(pend[pend.length - 1]);
+ break;
+ case "'":
+ tlm = mul([1, 0, 0, 1, 0, -(tl || 0)], tlm);
+ tm = tlm;
+ show(pend[pend.length - 1]);
+ break;
+ case '"':
+ tlm = mul([1, 0, 0, 1, 0, -(tl || 0)], tlm);
+ tm = tlm;
+ show(pend[pend.length - 1]);
+ break;
+ default:
+ break;
+ }
+ pend = [];
+ }
+ for (const b of blocks) {
+ if (!b.shows.length) b.selfContained = false;
+ for (const si of b.shows) {
+ const tj = tjs[si];
+ if (!tj.font || !fontIsT3(tj.font)) b.selfContained = false;
+ }
+ }
+ return { blocks, tjs };
+}
+
+function pageT3Fonts(op, pgBody) {
+ let resBody;
+ const rref = pgBody.match(/\/Resources\s+(\d+)\s+\d+\s+R/);
+ if (rref) resBody = op.objBody(parseInt(rref[1], 10))?.body ?? '';
+ else resBody = dictAt(pgBody, pgBody.indexOf('/Resources')) ?? '';
+ const fontDict = resBody && dictAt(resBody, resBody.indexOf('/Font'));
+ if (!fontDict) return null;
+ const t3 = new Set();
+ const nums = {};
+ for (const m of fontDict.matchAll(
+ /\/([A-Za-z0-9_.#-]+)\s+(\d+)\s+\d+\s+R/g
+ )) {
+ const num = parseInt(m[2], 10);
+ nums[m[1]] = num;
+ const fo = op.objBody(num);
+ if (fo && /\/Subtype\s*\/Type3\b/.test(fo.body)) t3.add(m[1]);
+ }
+ return t3.size
+ ? { t3, nums, resBody, resRef: rref ? parseInt(rref[1], 10) : -1 }
+ : null;
+}
+
+export async function protectType3Text(bytes) {
+ if (!probePdfBytes(bytes).type3) return null;
+ const src = latin(bytes);
+ if (!/\/Subtype\s*\/Type3\b/.test(src)) return null;
+ const op = await withObjStm(bytes, src, parsePdf(src));
+ if (!op) return null;
+ let next = op.size;
+ const newObjs = [],
+ pageRewrites = [];
+ const seg = {};
+
+ for (let pi = 0; pi < 4096; pi++) {
+ const pgNum = op.pageNum(pi);
+ if (pgNum < 0) break;
+ const pg = op.objBody(pgNum);
+ if (!pg) continue;
+ const fonts = pageT3Fonts(op, pg.body);
+ if (!fonts) continue;
+
+ const cOne = pg.body.match(/\/Contents\s+(\d+)\s+\d+\s+R/);
+ const cArr = pg.body.match(/\/Contents\s*\[([^\]]*)\]/);
+ const refs = cOne
+ ? [parseInt(cOne[1], 10)]
+ : cArr
+ ? [...cArr[1].matchAll(/(\d+)\s+\d+\s+R/g)].map((r) =>
+ parseInt(r[1], 10)
+ )
+ : [];
+ if (!refs.length) continue;
+ let content = '';
+ let ok = true;
+ for (const rn of refs) {
+ const o = op.objBody(rn);
+ const d = o && (await streamData(bytes, src, o));
+ if (d == null) {
+ ok = false;
+ break;
+ }
+ content += d + '\n';
+ }
+ if (!ok) continue;
+
+ const { blocks, tjs } = analyzeContent(content, (n) => fonts.t3.has(n));
+ seg[pi] = {
+ fonts: fonts.nums,
+ t3: [...fonts.t3],
+ tjs: tjs.map((j) => ({
+ e: j.e,
+ f: j.f,
+ font: j.font,
+ size: j.size,
+ codes: j.codes,
+ })),
+ };
+ const liftable = blocks.filter((b) => b.selfContained);
+ if (!liftable.length) continue;
+
+ const resNum = next++;
+ newObjs.push({ num: resNum, body: fonts.resBody.trim() || '<< >>' });
+
+ const added = [];
+ for (const b of [...liftable].sort((x, y) => y.start - x.start)) {
+ const en = b.entry;
+ const pre = [en.fill, en.strokeC, en.gsOp].filter(Boolean).join('\n');
+ const tstate = [
+ en.tf ? `/${en.tf} ${en.tfSize} Tf` : null,
+ en.tc,
+ en.tw,
+ en.tz,
+ en.ts,
+ en.trm,
+ en.tl != null ? `${en.tl} TL` : null,
+ ]
+ .filter(Boolean)
+ .join(' ');
+ let ops = content.slice(b.start, b.end);
+ if (tstate) ops = ops.replace(/^BT\b/, 'BT ' + tstate);
+ if (pre) ops = pre + '\n' + ops;
+ const num = next++;
+ const name = `ECT3${num}`;
+ newObjs.push({
+ num,
+ body:
+ `<< /Type /XObject /Subtype /Form /FormType 1` +
+ ` /BBox [ -100000 -100000 100000 100000 ]` +
+ ` /Resources ${resNum} 0 R /Length ${ops.length + 1} >>\nstream\n${ops}\nendstream`,
+ });
+ content =
+ content.slice(0, b.start) + `/${name} Do\n` + content.slice(b.end);
+ added.push({ name, num });
+ }
+
+ const cnum = next++;
+ newObjs.push({
+ num: cnum,
+ body: `<< /Length ${content.length + 1} >>\nstream\n${content}\nendstream`,
+ });
+ const entries = added.map((a) => `/${a.name} ${a.num} 0 R`).join(' ');
+ let newPage = pg.body.replace(
+ cOne ? cOne[0] : cArr[0],
+ `/Contents ${cnum} 0 R`
+ );
+ if (fonts.resRef >= 0) {
+ let res = fonts.resBody.trim();
+ const xo = res.match(/\/XObject\s*<<([\s\S]*?)>>/);
+ res = xo
+ ? res.replace(xo[0], `/XObject <<${xo[1]} ${entries} >>`)
+ : res.replace(/^<, `<< /XObject << ${entries} >> `);
+ pageRewrites.push({ num: fonts.resRef, body: res });
+ } else {
+ const resDict = dictAt(pg.body, pg.body.indexOf('/Resources'));
+ const xo = resDict.match(/\/XObject\s*<<([\s\S]*?)>>/);
+ const newRes = xo
+ ? resDict.replace(xo[0], `/XObject <<${xo[1]} ${entries} >>`)
+ : resDict.replace(/^<, `<< /XObject << ${entries} >> `);
+ newPage = newPage.replace(resDict, newRes);
+ }
+ pageRewrites.push({ num: pgNum, body: newPage });
+ }
+ if (!pageRewrites.length) return null;
+
+ const out = appendRevision(src, op.rootNum, next, pageRewrites, newObjs);
+ return { bytes: out, seg };
+}
+
+export async function protectFragileText(bytes) {
+ if (!probePdfBytes(bytes).quartz) return null;
+ const src = latin(bytes);
+ if (!/Quartz PDFContext/.test(src)) return null;
+ if (!/\/BaseFont\s*\/[A-Z]{6}\+/.test(src)) return null;
+ const op = await withObjStm(bytes, src, parsePdf(src));
+ if (!op) return null;
+ let next = op.size;
+ const newObjs = [],
+ pageRewrites = [];
+ const pages = [];
+
+ for (let pi = 0; pi < 4096; pi++) {
+ const pgNum = op.pageNum(pi);
+ if (pgNum < 0) break;
+ const pg = op.objBody(pgNum);
+ if (!pg) continue;
+ let resBody;
+ const rref = pg.body.match(/\/Resources\s+(\d+)\s+\d+\s+R/);
+ if (rref) resBody = op.objBody(parseInt(rref[1], 10))?.body ?? '';
+ else resBody = dictAt(pg.body, pg.body.indexOf('/Resources')) ?? '';
+ const fontDict = resBody && dictAt(resBody, resBody.indexOf('/Font'));
+ if (!fontDict || !/\/[A-Za-z0-9_.#-]+\s+\d+\s+\d+\s+R/.test(fontDict))
+ continue;
+
+ const cOne = pg.body.match(/\/Contents\s+(\d+)\s+\d+\s+R/);
+ const cArr = pg.body.match(/\/Contents\s*\[([^\]]*)\]/);
+ const refs = cOne
+ ? [parseInt(cOne[1], 10)]
+ : cArr
+ ? [...cArr[1].matchAll(/(\d+)\s+\d+\s+R/g)].map((r) =>
+ parseInt(r[1], 10)
+ )
+ : [];
+ if (!refs.length) continue;
+ let content = '';
+ let ok = true;
+ for (const rn of refs) {
+ const o = op.objBody(rn);
+ const d = o && (await streamData(bytes, src, o));
+ if (d == null) {
+ ok = false;
+ break;
+ }
+ content += d + '\n';
+ }
+ if (!ok) continue;
+
+ const { blocks } = analyzeContent(content, () => true);
+ const liftable = blocks.filter((b) => b.selfContained);
+ if (!liftable.length) continue;
+
+ const resNum = next++;
+ newObjs.push({ num: resNum, body: resBody.trim() || '<< >>' });
+ const added = [];
+ for (const b of [...liftable].sort((x, y) => y.start - x.start)) {
+ const en = b.entry;
+ const pre = [en.fill, en.strokeC, en.gsOp].filter(Boolean).join('\n');
+ const tstate = [
+ en.tf ? `/${en.tf} ${en.tfSize} Tf` : null,
+ en.tc,
+ en.tw,
+ en.tz,
+ en.ts,
+ en.trm,
+ en.tl != null ? `${en.tl} TL` : null,
+ ]
+ .filter(Boolean)
+ .join(' ');
+ let ops = content.slice(b.start, b.end);
+ if (tstate) ops = ops.replace(/^BT\b/, 'BT ' + tstate);
+ if (pre) ops = pre + '\n' + ops;
+ const num = next++;
+ const name = `ECFT${num}`;
+ newObjs.push({
+ num,
+ body:
+ `<< /Type /XObject /Subtype /Form /FormType 1` +
+ ` /BBox [ -100000 -100000 100000 100000 ]` +
+ ` /Resources ${resNum} 0 R /Length ${ops.length + 1} >>\nstream\n${ops}\nendstream`,
+ });
+ content =
+ content.slice(0, b.start) + `/${name} Do\n` + content.slice(b.end);
+ added.push({ name, num });
+ }
+
+ const cnum = next++;
+ newObjs.push({
+ num: cnum,
+ body: `<< /Length ${content.length + 1} >>\nstream\n${content}\nendstream`,
+ });
+ const entries = added.map((a) => `/${a.name} ${a.num} 0 R`).join(' ');
+ let newPage = pg.body.replace(
+ cOne ? cOne[0] : cArr[0],
+ `/Contents ${cnum} 0 R`
+ );
+ if (rref) {
+ let res = resBody.trim();
+ const xo = res.match(/\/XObject\s*<<([\s\S]*?)>>/);
+ res = xo
+ ? res.replace(xo[0], `/XObject <<${xo[1]} ${entries} >>`)
+ : res.replace(/^<, `<< /XObject << ${entries} >> `);
+ pageRewrites.push({ num: parseInt(rref[1], 10), body: res });
+ } else {
+ const resDict = dictAt(pg.body, pg.body.indexOf('/Resources'));
+ const xo = resDict.match(/\/XObject\s*<<([\s\S]*?)>>/);
+ const newRes = xo
+ ? resDict.replace(xo[0], `/XObject <<${xo[1]} ${entries} >>`)
+ : resDict.replace(/^<, `<< /XObject << ${entries} >> `);
+ newPage = newPage.replace(resDict, newRes);
+ }
+ pageRewrites.push({ num: pgNum, body: newPage });
+ pages.push(pi);
+ }
+ if (!pageRewrites.length) return null;
+ return {
+ bytes: appendRevision(src, op.rootNum, next, pageRewrites, newObjs),
+ pages,
+ };
+}
+
+export async function consolidateContentArrays(bytes) {
+ if (!probePdfBytes(bytes).contentsArray && bytes.length > 4 << 20)
+ return null;
+ const src = latin(bytes);
+ const rawHasArrays = /\/Contents\s*\[/.test(src);
+ if (!rawHasArrays && bytes.length > 4 << 20) return null;
+ const op = await withObjStm(bytes, src, parsePdf(src));
+ if (!op) return null;
+ let next = op.size;
+ const newObjs = [],
+ pageRewrites = [];
+ for (let pi = 0; pi < 4096; pi++) {
+ const pgNum = op.pageNum(pi);
+ if (pgNum < 0) break;
+ const pg = op.objBody(pgNum);
+ if (!pg) continue;
+ const cArr = pg.body.match(/\/Contents\s*\[([^\]]*)\]/);
+ if (!cArr) continue;
+ const refs = [...cArr[1].matchAll(/(\d+)\s+\d+\s+R/g)].map((r) =>
+ parseInt(r[1], 10)
+ );
+ if (refs.length < 2) continue;
+ let content = '';
+ let ok = true;
+ for (const rn of refs) {
+ const o = op.objBody(rn);
+ const d = o && (await streamData(bytes, src, o));
+ if (d == null) {
+ ok = false;
+ break;
+ }
+ content += d + '\n';
+ }
+ if (!ok) continue;
+ const cnum = next++;
+ newObjs.push({
+ num: cnum,
+ body: `<< /Length ${content.length} >>\nstream\n${content}endstream`,
+ });
+ pageRewrites.push({
+ num: pgNum,
+ body: pg.body.replace(cArr[0], `/Contents ${cnum} 0 R`),
+ });
+ }
+ if (!pageRewrites.length) return null;
+ return appendRevision(src, op.rootNum, next, pageRewrites, newObjs);
+}
+
+export function applyType3LeftoverSurgery(savedBytes, jobs) {
+ jobs = (jobs || []).filter(
+ (j) =>
+ j &&
+ j.seg &&
+ (j.state || []).some((o) => o.t3 && o.glyphs && o.glyphs.length)
+ );
+ if (!jobs.length) return null;
+ const saved = latin(savedBytes);
+ const sp = parsePdf(saved);
+ if (!sp) return null;
+ let next = sp.size;
+ const newObjs = [],
+ pageRewrites = [];
+ const injected = {};
+
+ for (const job of jobs) {
+ const leftovers = job.state.filter(
+ (o) => o.t3 && o.glyphs && o.glyphs.length
+ );
+ leftovers.sort((a, b) => {
+ const band = (o) => Math.round(-o.m[5] / 6);
+ return band(a) - band(b) || a.m[4] - b.m[4];
+ });
+ const tjs = job.seg.tjs || [];
+ const anchors = job.anchors || {};
+ const usedTj = new Set();
+ const lines = [];
+ const injectedTjs = [];
+ for (const o of leftovers) {
+ let name = anchors[o.font] ?? null;
+ const oCodes = o.glyphs.map((g) => g[0]);
+ if (!name) {
+ const sameCodes = (tj) =>
+ tj.codes.length === oCodes.length &&
+ tj.codes.every((c, k) => c === oCodes[k]);
+ let hit = tjs.findIndex(
+ (tj, i) =>
+ !usedTj.has(i) &&
+ Math.abs(tj.e - o.m[4]) < 0.05 &&
+ Math.abs(tj.f - o.m[5]) < 0.05 &&
+ sameCodes(tj)
+ );
+ if (hit < 0)
+ hit = tjs.findIndex((tj, i) => !usedTj.has(i) && sameCodes(tj));
+ if (hit < 0) {
+ hit = tjs.findIndex(
+ (tj, i) =>
+ !usedTj.has(i) &&
+ tj.codes.length >= oCodes.length &&
+ oCodes.every((c, k) => c === tj.codes[k])
+ );
+ }
+ if (hit < 0) {
+ console.warn('[type3] leftover glyph unmatched — skipped');
+ continue;
+ }
+ usedTj.add(hit);
+ name = tjs[hit].font;
+ }
+ const shownCodes = [];
+ const num6 = (v) => (Math.round(v * 10000) / 10000).toString();
+ const hex = (c) =>
+ '<' +
+ (c > 255
+ ? c.toString(16).padStart(4, '0')
+ : c.toString(16).padStart(2, '0')) +
+ '>';
+ let ops = 'q ';
+ ops += `${num6(o.fill[0] / 255)} ${num6(o.fill[1] / 255)} ${num6(o.fill[2] / 255)} rg `;
+ ops += `${num6(o.stroke[0] / 255)} ${num6(o.stroke[1] / 255)} ${num6(o.stroke[2] / 255)} RG `;
+ if (o.sw > 0) ops += `${num6(o.sw)} w `;
+ ops += `BT ${o.m.map(num6).join(' ')} Tm /${name} ${num6(o.size)} Tf `;
+ if (o.tr) ops += `${o.tr} Tr `;
+ const known = new Set();
+ for (const tj of tjs)
+ if (tj.font === name) tj.codes.forEach((c) => known.add(c));
+ let px = 0,
+ py = 0;
+ o.glyphs.forEach(([c, gx, gy], gi) => {
+ if (gi === 0) {
+ if (gx || gy) ops += `${num6(gx)} ${num6(gy)} Td `;
+ } else ops += `${num6(gx - px)} ${num6(gy - py)} Td `;
+ px = gx;
+ py = gy;
+ if (known.has(c)) {
+ ops += hex(c) + ' Tj ';
+ shownCodes.push(c);
+ }
+ });
+ ops += 'ET Q';
+ injectedTjs.push({
+ e: o.m[4],
+ f: o.m[5],
+ font: name,
+ size: o.size,
+ codes: shownCodes,
+ });
+ lines.push(ops);
+ }
+ if (!lines.length) continue;
+
+ const prefix = 'ET Q\n'.repeat(leftovers.length);
+ const body = prefix + lines.join('\n') + '\n';
+
+ const pgNum = sp.pageNum(job.pageIndex);
+ if (pgNum < 0) continue;
+ const spg = sp.objBody(pgNum);
+ if (!spg) continue;
+ const cOne = spg.body.match(/\/Contents\s+(\d+)\s+\d+\s+R/);
+ const cArr = spg.body.match(/\/Contents\s*\[([^\]]*)\]/);
+ if (!cOne && !cArr) continue;
+ const snum = next++;
+ newObjs.push({
+ num: snum,
+ body: `<< /Length ${body.length} >>\nstream\n${body}endstream`,
+ });
+ const refList = cOne ? `${cOne[1]} 0 R` : cArr[1].trim();
+ let newPage = spg.body.replace(
+ cOne ? cOne[0] : cArr[0],
+ `/Contents [ ${refList} ${snum} 0 R ]`
+ );
+
+ const usedNames = new Set(
+ (lines.join(' ').match(/\/([A-Za-z0-9_.#-]+)\s+[\d.]+\s+Tf/g) ?? []).map(
+ (s) => s.match(/\/([A-Za-z0-9_.#-]+)/)[1]
+ )
+ );
+ const rref = newPage.match(/\/Resources\s+(\d+)\s+\d+\s+R/);
+ if (rref) {
+ const rnum = parseInt(rref[1], 10);
+ const ro = sp.objBody(rnum);
+ if (ro) {
+ const res = mergeFonts(ro.body, usedNames, job.seg.fonts);
+ if (res !== ro.body) pageRewrites.push({ num: rnum, body: res });
+ }
+ } else {
+ newPage = mergeFonts(newPage, usedNames, job.seg.fonts);
+ }
+ pageRewrites.push({ num: pgNum, body: newPage });
+ injected[job.pageIndex] = injectedTjs;
+ }
+ if (!newObjs.length) return null;
+ return {
+ bytes: appendRevision(saved, sp.rootNum, next, pageRewrites, newObjs),
+ injected,
+ };
+}
+
+function mergeFonts(body, usedNames, fontNums) {
+ const resAt = body.indexOf('/Resources');
+ const resDict = resAt >= 0 ? dictAt(body, resAt) : dictAt(body, 0);
+ if (!resDict) return body;
+ const fontDict = dictAt(resDict, resDict.indexOf('/Font'));
+ const missing = [...usedNames].filter(
+ (n) =>
+ fontNums[n] != null &&
+ !(fontDict || '').match(
+ new RegExp('/' + n.replace(/[^A-Za-z0-9_]/g, '\\$&') + '[\\s/]')
+ )
+ );
+ if (!missing.length) return body;
+ const entries = missing.map((n) => `/${n} ${fontNums[n]} 0 R`).join(' ');
+ let newRes;
+ if (fontDict)
+ newRes = resDict.replace(
+ fontDict,
+ fontDict.replace(/^<, `<< ${entries} `)
+ );
+ else newRes = resDict.replace(/^<, `<< /Font << ${entries} >> `);
+ return body.replace(resDict, newRes);
+}
+
+function appendRevision(src, rootNum, size, pageRewrites, newObjs) {
+ let out = src;
+ if (!out.endsWith('\n')) out += '\n';
+ const offsets = new Map();
+ const emit = (num, body) => {
+ offsets.set(num, out.length);
+ out += `${num} 0 obj\n${body.trim()}\nendobj\n`;
+ };
+ for (const o of pageRewrites) emit(o.num, o.body);
+ for (const o of newObjs) emit(o.num, o.body);
+ const xrefAt = out.length;
+ const nums = [...offsets.keys()].sort((a, b) => a - b);
+ let xref = 'xref\n';
+ for (let i = 0; i < nums.length; ) {
+ let j = i;
+ while (j + 1 < nums.length && nums[j + 1] === nums[j] + 1) j++;
+ xref += `${nums[i]} ${j - i + 1}\n`;
+ for (let k = i; k <= j; k++)
+ xref += String(offsets.get(nums[k])).padStart(10, '0') + ' 00000 n \n';
+ i = j + 1;
+ }
+ out += xref;
+ const prevAt = src.lastIndexOf('startxref');
+ const prevNum = parseInt(src.slice(prevAt + 9).trim(), 10);
+ const infoM = src
+ .slice(src.lastIndexOf('trailer'))
+ .match(/\/Info\s+(\d+)\s+\d+\s+R/);
+ out +=
+ `trailer\n<< /Size ${size} /Root ${rootNum} 0 R` +
+ (infoM ? ` /Info ${infoM[1]} 0 R` : '') +
+ (Number.isFinite(prevNum) ? ` /Prev ${prevNum}` : '') +
+ ` >>\nstartxref\n${xrefAt}\n%%EOF\n`;
+ const u8 = new Uint8Array(out.length);
+ for (let i = 0; i < out.length; i++) u8[i] = out.charCodeAt(i) & 0xff;
+ return u8;
+}
diff --git a/src/js/handlers/fileHandler.ts b/src/js/handlers/fileHandler.ts
index fa5820bc..15aa863b 100644
--- a/src/js/handlers/fileHandler.ts
+++ b/src/js/handlers/fileHandler.ts
@@ -29,11 +29,7 @@ import {
} from '../config/pdf-tools.js';
import * as pdfjsLib from 'pdfjs-dist';
import { loadPdfDocument } from '../utils/load-pdf-document.js';
-
-pdfjsLib.GlobalWorkerOptions.workerSrc = new URL(
- 'pdfjs-dist/build/pdf.worker.min.mjs',
- import.meta.url
-).toString();
+import '../utils/setup-pdf-worker.js';
export {
getRotationState,
diff --git a/src/js/i18n/i18n.ts b/src/js/i18n/i18n.ts
index 30403050..df8f0f5f 100644
--- a/src/js/i18n/i18n.ts
+++ b/src/js/i18n/i18n.ts
@@ -1,5 +1,8 @@
import i18next from 'i18next';
import HttpBackend from 'i18next-http-backend';
+import { getStoredItem, setStoredItem } from '../utils/safe-storage.js';
+import enCommon from '../../../public/locales/en/common.json';
+import enTools from '../../../public/locales/en/tools.json';
// Supported languages
export const supportedLanguages = [
@@ -75,7 +78,7 @@ export const getLanguageFromUrl = (): SupportedLanguage => {
return langMatch[1] as SupportedLanguage;
}
- const storedLang = localStorage.getItem('i18nextLng');
+ const storedLang = getStoredItem('i18nextLng');
if (
storedLang &&
supportedLanguages.includes(storedLang as SupportedLanguage)
@@ -112,7 +115,7 @@ export const initI18n = async (): Promise => {
const currentLang = getLanguageFromUrl();
- localStorage.setItem('i18nextLng', currentLang);
+ setStoredItem('i18nextLng', currentLang);
await i18next.use(HttpBackend).init({
lng: currentLang,
@@ -121,6 +124,13 @@ export const initI18n = async (): Promise => {
ns: ['common', 'tools'],
defaultNS: 'common',
preload: [currentLang],
+ partialBundledLanguages: true,
+ resources: {
+ en: {
+ common: enCommon,
+ tools: enTools,
+ },
+ },
backend: {
loadPath: `${import.meta.env.BASE_URL.replace(/\/?$/, '/')}locales/{{lng}}/{{ns}}.json`,
},
@@ -141,7 +151,7 @@ export const t = (key: string, options?: Record): string => {
export const changeLanguage = (lang: SupportedLanguage): void => {
if (!supportedLanguages.includes(lang)) return;
- localStorage.setItem('i18nextLng', lang);
+ setStoredItem('i18nextLng', lang);
const basePath = import.meta.env.BASE_URL.replace(/\/$/, '');
let relativePath = window.location.pathname;
diff --git a/src/js/logic/add-watermark-page.ts b/src/js/logic/add-watermark-page.ts
index 79c06c64..0e547929 100644
--- a/src/js/logic/add-watermark-page.ts
+++ b/src/js/logic/add-watermark-page.ts
@@ -11,16 +11,17 @@ import {
addTextWatermark,
addImageWatermark,
parsePageRange,
+ computeTileCenters,
} from '../utils/pdf-operations.js';
-import { AddWatermarkState, PageWatermarkConfig } from '@/types';
+import {
+ AddWatermarkState,
+ PageWatermarkConfig,
+ WatermarkLayout,
+} from '@/types';
import * as pdfjsLib from 'pdfjs-dist';
import { loadPdfWithPasswordPrompt } from '../utils/password-prompt.js';
import { loadPdfDocument } from '../utils/load-pdf-document.js';
-
-pdfjsLib.GlobalWorkerOptions.workerSrc = new URL(
- 'pdfjs-dist/build/pdf.worker.min.mjs',
- import.meta.url
-).toString();
+import '../utils/setup-pdf-worker.js';
const pageState: AddWatermarkState = {
file: null,
@@ -31,9 +32,15 @@ const pageState: AddWatermarkState = {
watermarkY: 0.5,
};
+const WATERMARK_FONT_STACK =
+ '"Noto Sans SC", "Noto Sans JP", "Noto Sans KR", "Noto Sans Arabic", Arial, sans-serif';
+
let watermarkType: 'text' | 'image' = 'text';
+let watermarkLayout: WatermarkLayout = 'single';
let imageWatermarkDataUrl: string | null = null;
let imageWatermarkFile: File | null = null;
+let imageWatermarkBitmap: HTMLImageElement | null = null;
+let measureCanvas: HTMLCanvasElement | null = null;
let isDragging = false;
let dragOffsetX = 0;
let dragOffsetY = 0;
@@ -186,6 +193,7 @@ function resetState() {
pageState.watermarkY = 0.5;
imageWatermarkDataUrl = null;
imageWatermarkFile = null;
+ imageWatermarkBitmap = null;
cachedPdfjsDoc = null;
currentPageNum = 1;
totalPageCount = 1;
@@ -273,9 +281,19 @@ async function changePage(newPageNum: number) {
updateWatermarkOverlay();
}
+function readNumberInput(id: string, fallback: number): number {
+ const input = document.getElementById(id) as HTMLInputElement | null;
+ if (!input) return fallback;
+ const value = parseFloat(input.value);
+ return Number.isFinite(value) ? value : fallback;
+}
+
function getDefaultConfig(): PageWatermarkConfig {
return {
type: 'text',
+ layout: 'single',
+ tileGapX: 25,
+ tileGapY: 75,
x: 0.5,
y: 0.5,
text: '',
@@ -294,6 +312,9 @@ function getDefaultConfig(): PageWatermarkConfig {
function getCurrentConfig(): PageWatermarkConfig {
return {
type: watermarkType,
+ layout: watermarkLayout,
+ tileGapX: readNumberInput('tile-gap-x', 25),
+ tileGapY: readNumberInput('tile-gap-y', 75),
x: pageState.watermarkX,
y: pageState.watermarkY,
text:
@@ -345,11 +366,27 @@ function loadPageConfig(pageNum: number) {
}
watermarkType = config.type;
+ watermarkLayout = config.layout;
pageState.watermarkX = config.x;
pageState.watermarkY = config.y;
+ if (config.imageDataUrl !== imageWatermarkDataUrl) {
+ loadWatermarkBitmap(config.imageDataUrl);
+ }
imageWatermarkDataUrl = config.imageDataUrl;
imageWatermarkFile = config.imageFile;
+ const tileGapX = document.getElementById('tile-gap-x') as HTMLInputElement;
+ const tileGapY = document.getElementById('tile-gap-y') as HTMLInputElement;
+ const tileGapXValue = document.getElementById('tile-gap-x-value');
+ const tileGapYValue = document.getElementById('tile-gap-y-value');
+
+ if (tileGapX) tileGapX.value = String(config.tileGapX);
+ if (tileGapY) tileGapY.value = String(config.tileGapY);
+ if (tileGapXValue) tileGapXValue.textContent = String(config.tileGapX);
+ if (tileGapYValue) tileGapYValue.textContent = String(config.tileGapY);
+
+ applyLayoutUI();
+
const typeTextBtn = document.getElementById('type-text-btn');
const typeImageBtn = document.getElementById('type-image-btn');
const textOptions = document.getElementById('text-watermark-options');
@@ -511,6 +548,36 @@ function setupEditorControls() {
updateWatermarkOverlay();
});
+ const layoutSingleBtn = document.getElementById('layout-single-btn');
+ const layoutTileBtn = document.getElementById('layout-tile-btn');
+
+ layoutSingleBtn?.addEventListener('click', () => {
+ watermarkLayout = 'single';
+ applyLayoutUI();
+ updateWatermarkOverlay();
+ });
+
+ layoutTileBtn?.addEventListener('click', () => {
+ watermarkLayout = 'tile';
+ applyLayoutUI();
+ updateWatermarkOverlay();
+ });
+
+ const tileGapX = document.getElementById('tile-gap-x') as HTMLInputElement;
+ const tileGapY = document.getElementById('tile-gap-y') as HTMLInputElement;
+ const tileGapXValue = document.getElementById('tile-gap-x-value');
+ const tileGapYValue = document.getElementById('tile-gap-y-value');
+
+ tileGapX?.addEventListener('input', () => {
+ if (tileGapXValue) tileGapXValue.textContent = tileGapX.value;
+ updateWatermarkOverlay();
+ });
+
+ tileGapY?.addEventListener('input', () => {
+ if (tileGapYValue) tileGapYValue.textContent = tileGapY.value;
+ updateWatermarkOverlay();
+ });
+
const watermarkText = document.getElementById(
'watermark-text'
) as HTMLInputElement;
@@ -571,6 +638,7 @@ function setupEditorControls() {
const reader = new FileReader();
reader.onload = () => {
imageWatermarkDataUrl = reader.result as string;
+ loadWatermarkBitmap(imageWatermarkDataUrl);
updateWatermarkOverlay();
};
reader.readAsDataURL(file);
@@ -604,6 +672,161 @@ function updatePresetHighlight(x: number, y: number) {
});
}
+function loadWatermarkBitmap(dataUrl: string | null) {
+ if (!dataUrl) {
+ imageWatermarkBitmap = null;
+ return;
+ }
+ const image = new Image();
+ image.onload = () => {
+ if (imageWatermarkDataUrl !== dataUrl) return;
+ imageWatermarkBitmap = image;
+ updateWatermarkOverlay();
+ };
+ image.src = dataUrl;
+}
+
+function applyLayoutUI() {
+ const singleBtn = document.getElementById('layout-single-btn');
+ const tileBtn = document.getElementById('layout-tile-btn');
+ const tileOptions = document.getElementById('tile-options');
+ const positionSection = document.getElementById('position-section');
+ const dragHint = document.getElementById('drag-hint');
+
+ const activeClass =
+ 'flex-1 py-2 px-3 text-sm font-medium rounded-lg bg-indigo-600 text-white transition-colors';
+ const inactiveClass =
+ 'flex-1 py-2 px-3 text-sm font-medium rounded-lg bg-gray-700 text-gray-300 hover:bg-gray-600 transition-colors';
+
+ const isTile = watermarkLayout === 'tile';
+ if (singleBtn) singleBtn.className = isTile ? inactiveClass : activeClass;
+ if (tileBtn) tileBtn.className = isTile ? activeClass : inactiveClass;
+ tileOptions?.classList.toggle('hidden', !isTile);
+ positionSection?.classList.toggle('hidden', isTile);
+ dragHint?.classList.toggle('sm:inline', !isTile);
+}
+
+function measureTextWidth(text: string, fontSize: number): number {
+ if (!measureCanvas) measureCanvas = document.createElement('canvas');
+ const ctx = measureCanvas.getContext('2d');
+ if (!ctx) return 0;
+ ctx.font = `bold ${fontSize}px ${WATERMARK_FONT_STACK}`;
+ return ctx.measureText(text).width + 2;
+}
+
+function renderTilePreview() {
+ const canvas = document.getElementById(
+ 'tile-preview-canvas'
+ ) as HTMLCanvasElement;
+ const container = document.getElementById('preview-container');
+ if (!canvas || !container) return;
+
+ const displayWidth = container.clientWidth;
+ const displayHeight = container.clientHeight;
+ if (!displayWidth || !displayHeight || !pdfPageWidth || !pdfPageHeight)
+ return;
+
+ const dpr = 2;
+ canvas.width = Math.round(displayWidth * dpr);
+ canvas.height = Math.round(displayHeight * dpr);
+ canvas.style.width = displayWidth + 'px';
+ canvas.style.height = displayHeight + 'px';
+
+ const ctx = canvas.getContext('2d');
+ if (!ctx) return;
+ ctx.clearRect(0, 0, canvas.width, canvas.height);
+ ctx.scale(dpr, dpr);
+
+ const gapX = readNumberInput('tile-gap-x', 25) / 100;
+ const gapY = readNumberInput('tile-gap-y', 75) / 100;
+
+ let itemWidth: number;
+ let itemHeight: number;
+ let uiAngle: number;
+ let opacity: number;
+
+ if (watermarkType === 'text') {
+ const text =
+ (document.getElementById('watermark-text') as HTMLInputElement)?.value ||
+ 'CONFIDENTIAL';
+ const fontSize = readNumberInput('font-size', 72);
+ uiAngle = readNumberInput('angle-text', 0);
+ opacity = readNumberInput('opacity-text', 0.3);
+ itemWidth = measureTextWidth(text, fontSize);
+ itemHeight = fontSize * 1.4;
+
+ const centers = computeTileCenters({
+ pageWidth: pdfPageWidth,
+ pageHeight: pdfPageHeight,
+ itemWidth,
+ itemHeight,
+ angle: -uiAngle,
+ gapX,
+ gapY,
+ });
+
+ ctx.globalAlpha = opacity;
+ ctx.fillStyle =
+ (document.getElementById('text-color') as HTMLInputElement)?.value ||
+ '#888888';
+ ctx.font = `bold ${fontSize * previewScale}px ${WATERMARK_FONT_STACK}`;
+ ctx.textAlign = 'center';
+ ctx.textBaseline = 'middle';
+
+ for (const center of centers) {
+ ctx.save();
+ ctx.translate(
+ center.x * previewScale,
+ (pdfPageHeight - center.y) * previewScale
+ );
+ ctx.rotate((uiAngle * Math.PI) / 180);
+ ctx.fillText(text, 0, 0);
+ ctx.restore();
+ }
+ return;
+ }
+
+ const bitmap = imageWatermarkBitmap;
+ if (!bitmap || !bitmap.naturalWidth || !bitmap.naturalHeight) return;
+
+ const scale = readNumberInput('image-scale', 100) / 100;
+ uiAngle = readNumberInput('angle-image', 0);
+ opacity = readNumberInput('opacity-image', 0.3);
+ itemWidth = bitmap.naturalWidth * scale;
+ itemHeight = bitmap.naturalHeight * scale;
+
+ const centers = computeTileCenters({
+ pageWidth: pdfPageWidth,
+ pageHeight: pdfPageHeight,
+ itemWidth,
+ itemHeight,
+ angle: -uiAngle,
+ gapX,
+ gapY,
+ });
+
+ ctx.globalAlpha = opacity;
+ const drawWidth = itemWidth * previewScale;
+ const drawHeight = itemHeight * previewScale;
+
+ for (const center of centers) {
+ ctx.save();
+ ctx.translate(
+ center.x * previewScale,
+ (pdfPageHeight - center.y) * previewScale
+ );
+ ctx.rotate((uiAngle * Math.PI) / 180);
+ ctx.drawImage(
+ bitmap,
+ -drawWidth / 2,
+ -drawHeight / 2,
+ drawWidth,
+ drawHeight
+ );
+ ctx.restore();
+ }
+}
+
function updateWatermarkOverlay() {
const box = document.getElementById('watermark-box') as HTMLElement;
const textOverlay = document.getElementById(
@@ -619,6 +842,18 @@ function updateWatermarkOverlay() {
const containerW = container.clientWidth;
const containerH = container.clientHeight;
+ const tileCanvas = document.getElementById('tile-preview-canvas');
+
+ if (watermarkLayout === 'tile') {
+ box.classList.add('hidden');
+ textOverlay.classList.add('hidden');
+ imageOverlay.classList.add('hidden');
+ tileCanvas?.classList.remove('hidden');
+ renderTilePreview();
+ return;
+ }
+
+ tileCanvas?.classList.add('hidden');
if (watermarkType === 'text') {
box.classList.remove('hidden');
@@ -846,6 +1081,9 @@ async function applyWatermark() {
x: config.x,
y: posY,
pageIndices,
+ tile: config.layout === 'tile',
+ tileGapX: config.tileGapX / 100,
+ tileGapY: config.tileGapY / 100,
})
);
} else {
@@ -875,6 +1113,9 @@ async function applyWatermark() {
x: config.x,
y: posY,
pageIndices,
+ tile: config.layout === 'tile',
+ tileGapX: config.tileGapX / 100,
+ tileGapY: config.tileGapY / 100,
})
);
}
@@ -896,6 +1137,9 @@ async function applyWatermark() {
const key = JSON.stringify({
type: config.type,
+ layout: config.layout,
+ tileGapX: config.tileGapX,
+ tileGapY: config.tileGapY,
x: config.x,
y: config.y,
text: config.text,
@@ -930,6 +1174,9 @@ async function applyWatermark() {
x: config.x,
y: posY,
pageIndices: indices,
+ tile: config.layout === 'tile',
+ tileGapX: config.tileGapX / 100,
+ tileGapY: config.tileGapY / 100,
})
);
} else {
@@ -955,6 +1202,9 @@ async function applyWatermark() {
x: config.x,
y: posY,
pageIndices: indices,
+ tile: config.layout === 'tile',
+ tileGapX: config.tileGapX / 100,
+ tileGapY: config.tileGapY / 100,
})
);
}
diff --git a/src/js/logic/adjust-colors-page.ts b/src/js/logic/adjust-colors-page.ts
index 2ed9e00c..2a7e7a0d 100644
--- a/src/js/logic/adjust-colors-page.ts
+++ b/src/js/logic/adjust-colors-page.ts
@@ -12,11 +12,7 @@ import * as pdfjsLib from 'pdfjs-dist';
import type { AdjustColorsSettings } from '../types/adjust-colors-type.js';
import { t } from '../i18n/i18n';
import { loadPdfWithPasswordPrompt } from '../utils/password-prompt.js';
-
-pdfjsLib.GlobalWorkerOptions.workerSrc = new URL(
- 'pdfjs-dist/build/pdf.worker.min.mjs',
- import.meta.url
-).toString();
+import '../utils/setup-pdf-worker.js';
let files: File[] = [];
let cachedBaselineData: ImageData | null = null;
diff --git a/src/js/logic/bookmark-pdf.ts b/src/js/logic/bookmark-pdf.ts
index ae938152..be4c0b58 100644
--- a/src/js/logic/bookmark-pdf.ts
+++ b/src/js/logic/bookmark-pdf.ts
@@ -14,6 +14,7 @@ import {
} from '../utils/helpers.js';
import { loadPdfWithPasswordPrompt } from '../utils/password-prompt.js';
import { loadPdfDocument } from '../utils/load-pdf-document.js';
+import '../utils/setup-pdf-worker.js';
import {
BookmarkNode,
BookmarkTree,
@@ -32,11 +33,6 @@ import {
PDF_COLOR_MAP,
} from '@/types';
-pdfjsLib.GlobalWorkerOptions.workerSrc = new URL(
- 'pdfjs-dist/build/pdf.worker.min.mjs',
- import.meta.url
-).toString();
-
const modalContainer = document.getElementById(
'modal-container'
) as HTMLElement | null;
diff --git a/src/js/logic/combine-single-page-page.ts b/src/js/logic/combine-single-page-page.ts
index 0b0df811..77127011 100644
--- a/src/js/logic/combine-single-page-page.ts
+++ b/src/js/logic/combine-single-page-page.ts
@@ -11,11 +11,7 @@ import * as pdfjsLib from 'pdfjs-dist';
import { CombineSinglePageState } from '@/types';
import { loadPdfWithPasswordPrompt } from '../utils/password-prompt.js';
import { loadPdfDocument } from '../utils/load-pdf-document.js';
-
-pdfjsLib.GlobalWorkerOptions.workerSrc = new URL(
- 'pdfjs-dist/build/pdf.worker.min.mjs',
- import.meta.url
-).toString();
+import '../utils/setup-pdf-worker.js';
const pageState: CombineSinglePageState = {
file: null,
diff --git a/src/js/logic/compare-pdfs-page.ts b/src/js/logic/compare-pdfs-page.ts
index 2aa3e70f..7cb29655 100644
--- a/src/js/logic/compare-pdfs-page.ts
+++ b/src/js/logic/compare-pdfs-page.ts
@@ -19,17 +19,13 @@ import type {
import { exportComparePdf } from '../compare/reporting/export-compare-pdf.ts';
import { LRUCache } from '../compare/lru-cache.ts';
import { COMPARE_CACHE_MAX_SIZE } from '../compare/config.ts';
+import '../utils/setup-pdf-worker.js';
import {
getElement,
computeComparisonForPair,
getComparisonCacheKey,
} from './compare-render.ts';
-pdfjsLib.GlobalWorkerOptions.workerSrc = new URL(
- 'pdfjs-dist/build/pdf.worker.min.mjs',
- import.meta.url
-).toString();
-
const pageState: CompareState = {
pdfDoc1: null,
pdfDoc2: null,
diff --git a/src/js/logic/compress-pdf-page.ts b/src/js/logic/compress-pdf-page.ts
index 587a4e38..99280e8b 100644
--- a/src/js/logic/compress-pdf-page.ts
+++ b/src/js/logic/compress-pdf-page.ts
@@ -13,11 +13,7 @@ import { showWasmRequiredDialog } from '../utils/wasm-provider.js';
import { loadPyMuPDF, isPyMuPDFAvailable } from '../utils/pymupdf-loader.js';
import * as pdfjsLib from 'pdfjs-dist';
import type { PDFDocumentProxy } from 'pdfjs-dist';
-
-pdfjsLib.GlobalWorkerOptions.workerSrc = new URL(
- 'pdfjs-dist/build/pdf.worker.min.mjs',
- import.meta.url
-).toString();
+import '../utils/setup-pdf-worker.js';
const CONDENSE_PRESETS = {
light: {
diff --git a/src/js/logic/crop-pdf-page.ts b/src/js/logic/crop-pdf-page.ts
index 6e805158..b1d5b58e 100644
--- a/src/js/logic/crop-pdf-page.ts
+++ b/src/js/logic/crop-pdf-page.ts
@@ -7,11 +7,7 @@ import * as pdfjsLib from 'pdfjs-dist';
import { PDFDocument as PDFLibDocument } from 'pdf-lib';
import { CropperState, CropPercentages } from '@/types';
import { loadPdfDocument } from '../utils/load-pdf-document.js';
-
-pdfjsLib.GlobalWorkerOptions.workerSrc = new URL(
- 'pdfjs-dist/build/pdf.worker.min.mjs',
- import.meta.url
-).toString();
+import '../utils/setup-pdf-worker.js';
const cropperState: CropperState = {
pdfDoc: null,
diff --git a/src/js/logic/cropper.ts b/src/js/logic/cropper.ts
index 2c58afb5..be55fb05 100644
--- a/src/js/logic/cropper.ts
+++ b/src/js/logic/cropper.ts
@@ -10,14 +10,10 @@ import * as pdfjsLib from 'pdfjs-dist';
import { PDFDocument as PDFLibDocument } from 'pdf-lib';
import { loadPdfDocument } from '../utils/load-pdf-document.js';
-pdfjsLib.GlobalWorkerOptions.workerSrc = new URL(
- 'pdfjs-dist/build/pdf.worker.min.mjs',
- import.meta.url
-).toString();
-
// --- Global State for the Cropper Tool ---
import type { CropPercentages } from '@/types';
import type { PDFDocumentProxy } from 'pdfjs-dist';
+import '../utils/setup-pdf-worker.js';
const cropperState: {
pdfDoc: PDFDocumentProxy | null;
diff --git a/src/js/logic/delete-pages-page.ts b/src/js/logic/delete-pages-page.ts
index 31af8742..6b711ee3 100644
--- a/src/js/logic/delete-pages-page.ts
+++ b/src/js/logic/delete-pages-page.ts
@@ -10,11 +10,7 @@ import { deletePdfPages } from '../utils/pdf-operations.js';
import * as pdfjsLib from 'pdfjs-dist';
import { DeletePagesState } from '@/types';
import { loadPdfDocument } from '../utils/load-pdf-document.js';
-
-pdfjsLib.GlobalWorkerOptions.workerSrc = new URL(
- 'pdfjs-dist/build/pdf.worker.min.mjs',
- import.meta.url
-).toString();
+import '../utils/setup-pdf-worker.js';
const deleteState: DeletePagesState = {
file: null,
diff --git a/src/js/logic/digital-sign-pdf.ts b/src/js/logic/digital-sign-pdf.ts
index b9630a6d..59729b5a 100644
--- a/src/js/logic/digital-sign-pdf.ts
+++ b/src/js/logic/digital-sign-pdf.ts
@@ -120,7 +120,7 @@ const CORS_PROXY_URL = import.meta.env.VITE_CORS_PROXY_URL || '';
*
* This client-side HMAC provides limited protection (deters casual abuse)
* but should NOT be considered secure against determined attackers. BentoPDF
- * accepts this tradeoff because of it's client side architecture.
+ * accepts this tradeoff because of its client-side architecture.
*
* To enable (optional):
* 1. Generate a secret: openssl rand -hex 32
diff --git a/src/js/logic/duplex-collate-page.ts b/src/js/logic/duplex-collate-page.ts
new file mode 100644
index 00000000..c4adefee
--- /dev/null
+++ b/src/js/logic/duplex-collate-page.ts
@@ -0,0 +1,664 @@
+import { createIcons, icons } from 'lucide';
+import { showAlert, showLoader, hideLoader } from '../ui.js';
+import { formatBytes, downloadFile } from '../utils/helpers.js';
+import { loadPdfWithPasswordPrompt } from '../utils/password-prompt.js';
+import { loadPdfDocument } from '../utils/load-pdf-document.js';
+import { t } from '../i18n/index.js';
+import { PDFDocument } from 'pdf-lib';
+import JSZip from 'jszip';
+
+interface DuplexState {
+ file: File | null;
+ pdfDoc: PDFDocument | null;
+ totalPages: number;
+}
+
+const duplexState: DuplexState = {
+ file: null,
+ pdfDoc: null,
+ totalPages: 0,
+};
+
+const translate = (
+ key: string,
+ fallback: string,
+ options?: Record
+) => {
+ const translation = t(key, options);
+ return translation && translation !== key ? translation : fallback;
+};
+
+if (document.readyState === 'loading') {
+ document.addEventListener('DOMContentLoaded', initializePage);
+} else {
+ initializePage();
+}
+
+/**
+ * Wires duplex-collate page event handlers and initial UI behavior.
+ */
+function initializePage() {
+ createIcons({ icons });
+
+ const fileInput = document.getElementById('file-input') as HTMLInputElement;
+ const dropZone = document.getElementById('drop-zone');
+ const processBtn = document.getElementById('process-btn');
+ const autoSplitBtn = document.getElementById('auto-split-btn');
+
+ if (fileInput) {
+ fileInput.addEventListener('change', handleFileUpload);
+ fileInput.addEventListener('click', () => {
+ fileInput.value = '';
+ });
+ }
+
+ 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('drop', (e) => {
+ e.preventDefault();
+ dropZone.classList.remove('bg-gray-700');
+ const droppedFiles = e.dataTransfer?.files;
+ if (droppedFiles && droppedFiles.length > 0) {
+ handleFile(droppedFiles[0]);
+ }
+ });
+ }
+
+ processBtn?.addEventListener('click', processDuplexCollate);
+
+ autoSplitBtn?.addEventListener('click', () => {
+ const splitInput = document.getElementById(
+ 'split-page'
+ ) as HTMLInputElement | null;
+ if (!splitInput || duplexState.totalPages < 2) return;
+ splitInput.value = Math.ceil(duplexState.totalPages / 2).toString();
+ updatePreviewSummary();
+ });
+
+ document
+ .getElementById('split-page')
+ ?.addEventListener('input', updatePreviewSummary);
+ document
+ .getElementById('back-order')
+ ?.addEventListener('change', updatePreviewSummary);
+ document
+ .getElementById('export-grouped')
+ ?.addEventListener('change', toggleGroupedOptions);
+
+ document.getElementById('back-to-tools')?.addEventListener('click', () => {
+ window.location.href = import.meta.env.BASE_URL;
+ });
+}
+
+/**
+ * Handles file input selection events and forwards the chosen file for parsing.
+ */
+function handleFileUpload(e: Event) {
+ const input = e.target as HTMLInputElement;
+ if (input.files && input.files.length > 0) {
+ handleFile(input.files[0]);
+ }
+}
+
+/**
+ * Loads and validates the uploaded PDF, then prepares UI state for duplex collation.
+ */
+async function handleFile(file: File) {
+ if (
+ file.type !== 'application/pdf' &&
+ !file.name.toLowerCase().endsWith('.pdf')
+ ) {
+ showAlert(
+ translate('tools:duplexCollate.invalidFileTitle', 'Invalid File'),
+ translate(
+ 'tools:duplexCollate.invalidFileMessage',
+ 'Please select a PDF file.'
+ )
+ );
+ return;
+ }
+
+ showLoader(translate('tools:duplexCollate.loadingPdf', 'Loading PDF...'));
+
+ try {
+ const result = await loadPdfWithPasswordPrompt(file);
+ if (!result) {
+ hideLoader();
+ return;
+ }
+
+ result.pdf.destroy();
+ duplexState.file = result.file;
+ duplexState.pdfDoc = await loadPdfDocument(result.bytes);
+ duplexState.totalPages = duplexState.pdfDoc.getPageCount();
+
+ updateFileDisplay();
+ showOptions();
+ showOddPageWarning();
+ applyDefaultSplitPoint();
+ updatePreviewSummary();
+ hideLoader();
+ } catch (error) {
+ console.error('Error loading PDF:', error);
+ hideLoader();
+ showAlert(
+ translate('common.error', 'Error'),
+ translate(
+ 'tools:duplexCollate.loadErrorMessage',
+ 'Failed to load PDF file.'
+ )
+ );
+ }
+}
+
+/**
+ * Removes the .pdf extension from a filename.
+ */
+function stripPdfExtension(name: string): string {
+ return name.replace(/\.pdf$/i, '');
+}
+
+/**
+ * Resolves a safe base filename for generated outputs.
+ */
+function getBaseFilename(): string {
+ return stripPdfExtension(duplexState.file?.name || 'document');
+}
+
+/**
+ * Renders the selected file metadata and remove action in the file list area.
+ */
+function updateFileDisplay() {
+ const fileDisplayArea = document.getElementById('file-display-area');
+ if (!fileDisplayArea || !duplexState.file) return;
+
+ fileDisplayArea.innerHTML = '';
+
+ 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 nameSpan = document.createElement('div');
+ nameSpan.className = 'truncate font-medium text-gray-200 text-sm mb-1';
+ nameSpan.textContent = duplexState.file.name;
+
+ const metaSpan = document.createElement('div');
+ metaSpan.className = 'text-xs text-gray-400';
+ metaSpan.textContent = translate(
+ 'tools:duplexCollate.fileMeta',
+ `${formatBytes(duplexState.file.size)} - ${duplexState.totalPages} pages`,
+ {
+ size: formatBytes(duplexState.file.size),
+ count: duplexState.totalPages,
+ }
+ );
+
+ 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 = '';
+ removeBtn.onclick = resetState;
+
+ fileDiv.append(infoContainer, removeBtn);
+ fileDisplayArea.appendChild(fileDiv);
+ createIcons({ icons });
+}
+
+/**
+ * Reveals duplex options and updates the total page counter.
+ */
+function showOptions() {
+ const options = document.getElementById('duplex-options');
+ const totalPagesEl = document.getElementById('total-pages');
+
+ options?.classList.remove('hidden');
+ if (totalPagesEl) {
+ totalPagesEl.textContent = duplexState.totalPages.toString();
+ }
+}
+
+/**
+ * Shows a warning when the uploaded document has an odd page count.
+ */
+function showOddPageWarning() {
+ const banner = document.getElementById('odd-page-banner');
+ if (!banner) return;
+ if (duplexState.totalPages % 2 !== 0) {
+ banner.classList.remove('hidden');
+ banner.textContent = translate(
+ 'tools:duplexCollate.oddPageWarning',
+ `Odd page count (${duplexState.totalPages}). A duplex scan should have an even number of pages; you may have a missing or extra page.`,
+ {
+ count: duplexState.totalPages,
+ }
+ );
+ } else {
+ banner.classList.add('hidden');
+ banner.textContent = '';
+ }
+}
+
+/**
+ * Sets the split input to the midpoint of the uploaded document.
+ */
+function applyDefaultSplitPoint() {
+ const splitInput = document.getElementById(
+ 'split-page'
+ ) as HTMLInputElement | null;
+ if (!splitInput) return;
+ splitInput.value = Math.ceil(duplexState.totalPages / 2).toString();
+}
+
+/**
+ * Toggles grouped-export inputs based on the grouped export checkbox.
+ */
+function toggleGroupedOptions() {
+ const grouped = document.getElementById(
+ 'export-grouped'
+ ) as HTMLInputElement | null;
+ const groupPanel = document.getElementById('grouped-options');
+
+ if (!grouped || !groupPanel) return;
+ if (grouped.checked) {
+ groupPanel.classList.remove('hidden');
+ } else {
+ groupPanel.classList.add('hidden');
+ }
+}
+
+/**
+ * Returns a clamped split point derived from the current UI input.
+ */
+function getSplitPoint(): number {
+ const splitInput = document.getElementById(
+ 'split-page'
+ ) as HTMLInputElement | null;
+ const fallback = Math.ceil(duplexState.totalPages / 2);
+ if (!splitInput) return fallback;
+
+ const parsed = Number.parseInt(splitInput.value, 10);
+ if (!Number.isFinite(parsed)) return fallback;
+
+ return Math.max(1, Math.min(parsed, Math.max(duplexState.totalPages - 1, 1)));
+}
+
+/**
+ * Returns the selected back-page ordering strategy.
+ */
+function getBackOrder(): 'reverse' | 'keep' {
+ const backOrder = document.getElementById(
+ 'back-order'
+ ) as HTMLSelectElement | null;
+ return backOrder?.value === 'keep' ? 'keep' : 'reverse';
+}
+
+/**
+ * Builds the final page index order by interleaving front and back page blocks.
+ */
+export function buildDuplexOrder(
+ totalPages: number,
+ splitPoint: number,
+ backOrder: 'reverse' | 'keep'
+) {
+ const fronts = Array.from({ length: splitPoint }, (_, i) => i);
+ const backs = Array.from(
+ { length: totalPages - splitPoint },
+ (_, i) => splitPoint + i
+ );
+
+ if (backOrder === 'reverse') {
+ backs.reverse();
+ }
+
+ const order: number[] = [];
+ const pairCount = Math.max(fronts.length, backs.length);
+
+ for (let i = 0; i < pairCount; i++) {
+ if (fronts[i] !== undefined) order.push(fronts[i]);
+ if (backs[i] !== undefined) order.push(backs[i]);
+ }
+
+ return {
+ order,
+ frontCount: fronts.length,
+ backCount: backs.length,
+ };
+}
+
+/**
+ * Refreshes the preview summary and warnings for the current collation settings.
+ */
+function updatePreviewSummary() {
+ const summary = document.getElementById('duplex-preview-summary');
+ const warning = document.getElementById('duplex-warning');
+ if (!summary || !warning || duplexState.totalPages === 0) return;
+
+ const splitPoint = getSplitPoint();
+ const backOrder = getBackOrder();
+ const { order, frontCount, backCount } = buildDuplexOrder(
+ duplexState.totalPages,
+ splitPoint,
+ backOrder
+ );
+
+ summary.textContent = translate(
+ 'tools:duplexCollate.previewSummary',
+ `Front block: ${frontCount} page(s), back block: ${backCount} page(s). Output: ${order.length} page(s), in front/back sequence.`,
+ {
+ frontCount,
+ backCount,
+ outputCount: order.length,
+ }
+ );
+
+ if (frontCount !== backCount) {
+ warning.classList.remove('hidden');
+ warning.textContent = translate(
+ 'tools:duplexCollate.previewMismatchWarning',
+ `Front and back block lengths differ (${frontCount} front vs ${backCount} back). The ${Math.abs(frontCount - backCount)} extra page(s) on the longer side will be appended unpaired at the end.`,
+ {
+ frontCount,
+ backCount,
+ extraCount: Math.abs(frontCount - backCount),
+ }
+ );
+ } else {
+ warning.classList.add('hidden');
+ warning.textContent = '';
+ }
+}
+
+/**
+ * Shows an in-page confirmation dialog and resolves with the user's choice.
+ */
+function showConfirm(message: string): Promise {
+ const modal = document.getElementById('confirm-modal');
+ const messageEl = document.getElementById('confirm-message');
+ const cancelBtn = document.getElementById('confirm-cancel');
+ const proceedBtn = document.getElementById('confirm-proceed');
+
+ if (!modal || !messageEl || !cancelBtn || !proceedBtn) {
+ return Promise.resolve(window.confirm(message));
+ }
+
+ messageEl.textContent = message;
+ modal.classList.remove('hidden');
+
+ const previousActive = document.activeElement as HTMLElement | null;
+
+ return new Promise((resolve) => {
+ const close = (result: boolean) => {
+ modal.classList.add('hidden');
+ cancelBtn.removeEventListener('click', onCancel);
+ proceedBtn.removeEventListener('click', onProceed);
+ modal.removeEventListener('click', onBackdrop);
+ document.removeEventListener('keydown', onKeydown);
+ previousActive?.focus();
+ resolve(result);
+ };
+
+ const onCancel = () => close(false);
+ const onProceed = () => close(true);
+ const onBackdrop = (e: MouseEvent) => {
+ if (e.target === modal) close(false);
+ };
+ const onKeydown = (e: KeyboardEvent) => {
+ if (e.key === 'Escape') close(false);
+ };
+
+ cancelBtn.addEventListener('click', onCancel);
+ proceedBtn.addEventListener('click', onProceed);
+ modal.addEventListener('click', onBackdrop);
+ document.addEventListener('keydown', onKeydown);
+ cancelBtn.focus();
+ });
+}
+
+/**
+ * Creates a new PDF containing the source pages referenced by index.
+ */
+async function createPdfFromIndices(
+ sourceDoc: PDFDocument,
+ indices: number[]
+): Promise {
+ const outDoc = await PDFDocument.create();
+ const copiedPages = await outDoc.copyPages(sourceDoc, indices);
+ copiedPages.forEach((page) => outDoc.addPage(page));
+ return new Uint8Array(await outDoc.save());
+}
+
+/**
+ * Converts Uint8Array output into an ArrayBuffer suitable for Blob construction.
+ */
+function bytesToArrayBuffer(bytes: Uint8Array): ArrayBuffer {
+ const { buffer, byteOffset, byteLength } = bytes;
+ if (buffer instanceof ArrayBuffer) {
+ return buffer.slice(byteOffset, byteOffset + byteLength);
+ }
+ const copy = new Uint8Array(byteLength);
+ copy.set(bytes);
+ return copy.buffer;
+}
+
+/**
+ * Runs duplex collation and downloads either a single file or grouped ZIP output.
+ */
+async function processDuplexCollate() {
+ if (!duplexState.file || !duplexState.pdfDoc || duplexState.totalPages < 2) {
+ showAlert(
+ translate('tools:duplexCollate.missingFileTitle', 'Missing File'),
+ translate(
+ 'tools:duplexCollate.missingFileMessage',
+ 'Please upload a PDF with at least 2 pages before processing.'
+ )
+ );
+ return;
+ }
+
+ const splitPoint = getSplitPoint();
+ if (splitPoint <= 0 || splitPoint >= duplexState.totalPages) {
+ showAlert(
+ translate('tools:duplexCollate.invalidSplitTitle', 'Invalid Split Point'),
+ translate(
+ 'tools:duplexCollate.invalidSplitMessage',
+ `Split point must be between page 1 and page ${duplexState.totalPages - 1}.`,
+ {
+ maxSplit: duplexState.totalPages - 1,
+ }
+ )
+ );
+ return;
+ }
+
+ const backOrder = getBackOrder();
+
+ // Warn explicitly if the blocks will be unequal (likely a scan error)
+ const { frontCount, backCount } = buildDuplexOrder(
+ duplexState.totalPages,
+ splitPoint,
+ backOrder
+ );
+ if (frontCount !== backCount) {
+ const proceed = await showConfirm(
+ translate(
+ 'tools:duplexCollate.unevenConfirmMessage',
+ `The front block has ${frontCount} page(s) and the back block has ${backCount} page(s).\nThis usually means a page was scanned twice or is missing.\n\nThe ${Math.abs(frontCount - backCount)} unpaired page(s) will be appended at the end.\n\nContinue anyway?`,
+ {
+ frontCount,
+ backCount,
+ extraCount: Math.abs(frontCount - backCount),
+ }
+ )
+ );
+ if (!proceed) return;
+ }
+
+ const groupedCheckbox = document.getElementById(
+ 'export-grouped'
+ ) as HTMLInputElement | null;
+ const pagesPerDocInput = document.getElementById(
+ 'pages-per-document'
+ ) as HTMLInputElement | null;
+
+ const exportGrouped = groupedCheckbox?.checked === true;
+ const pagesPerDocument = Number.parseInt(pagesPerDocInput?.value || '0', 10);
+
+ if (
+ exportGrouped &&
+ (!Number.isFinite(pagesPerDocument) || pagesPerDocument < 1)
+ ) {
+ showAlert(
+ translate('tools:duplexCollate.invalidGroupTitle', 'Invalid Group Size'),
+ translate(
+ 'tools:duplexCollate.invalidGroupMessage',
+ 'Please enter a valid number of pages per original document.'
+ )
+ );
+ return;
+ }
+
+ showLoader(
+ translate('tools:duplexCollate.collatingLoader', 'Collating duplex scan...')
+ );
+
+ try {
+ const { order } = buildDuplexOrder(
+ duplexState.totalPages,
+ splitPoint,
+ backOrder
+ );
+ const baseName = getBaseFilename();
+
+ if (!exportGrouped) {
+ const bytes = await createPdfFromIndices(duplexState.pdfDoc, order);
+ const blob = new Blob([bytesToArrayBuffer(bytes)], {
+ type: 'application/pdf',
+ });
+ downloadFile(blob, `${baseName}_collated.pdf`);
+ hideLoader();
+ showAlert(
+ translate('common.success', 'Success'),
+ translate(
+ 'tools:duplexCollate.successMessage',
+ 'Collated PDF generated successfully.'
+ ),
+ 'success'
+ );
+ return;
+ }
+
+ const documents: Array<{ name: string; bytes: Uint8Array }> = [];
+ for (let start = 0; start < order.length; start += pagesPerDocument) {
+ const chunk = order.slice(start, start + pagesPerDocument);
+ if (chunk.length === 0) continue;
+ const chunkBytes = await createPdfFromIndices(duplexState.pdfDoc, chunk);
+ documents.push({
+ name: `${baseName}_doc_${documents.length + 1}.pdf`,
+ bytes: chunkBytes,
+ });
+ }
+
+ const fileCount = documents.length;
+
+ if (fileCount === 0) {
+ hideLoader();
+ showAlert(
+ translate('common.error', 'Error'),
+ translate(
+ 'tools:duplexCollate.processErrorMessage',
+ 'Failed to collate PDF.'
+ )
+ );
+ return;
+ }
+
+ if (fileCount === 1) {
+ const blob = new Blob([bytesToArrayBuffer(documents[0].bytes)], {
+ type: 'application/pdf',
+ });
+ downloadFile(blob, documents[0].name);
+ } else {
+ const zip = new JSZip();
+ for (const { name, bytes } of documents) {
+ zip.file(name, bytes);
+ }
+ const zipBlob = await zip.generateAsync({ type: 'blob' });
+ downloadFile(zipBlob, `${baseName}_collated_grouped.zip`);
+ }
+
+ hideLoader();
+ showAlert(
+ translate('common.success', 'Success'),
+ translate(
+ 'tools:duplexCollate.successGroupedMessage',
+ `Collation complete. Generated ${fileCount} grouped file(s).`,
+ {
+ fileCount,
+ }
+ ),
+ 'success'
+ );
+ } catch (error) {
+ console.error('Duplex collate error:', error);
+ hideLoader();
+ showAlert(
+ translate('common.error', 'Error'),
+ translate(
+ 'tools:duplexCollate.processErrorMessage',
+ 'Failed to collate PDF.'
+ )
+ );
+ }
+}
+
+/**
+ * Clears selected file data and restores the page UI to its initial state.
+ */
+function resetState() {
+ duplexState.file = null;
+ duplexState.pdfDoc = null;
+ duplexState.totalPages = 0;
+
+ const options = document.getElementById('duplex-options');
+ const fileDisplayArea = document.getElementById('file-display-area');
+ const oddBanner = document.getElementById('odd-page-banner');
+ if (oddBanner) {
+ oddBanner.classList.add('hidden');
+ oddBanner.textContent = '';
+ }
+ const splitInput = document.getElementById(
+ 'split-page'
+ ) as HTMLInputElement | null;
+ const groupedCheckbox = document.getElementById(
+ 'export-grouped'
+ ) as HTMLInputElement | null;
+ const pagesPerDocInput = document.getElementById(
+ 'pages-per-document'
+ ) as HTMLInputElement | null;
+ const warning = document.getElementById('duplex-warning');
+ const summary = document.getElementById('duplex-preview-summary');
+
+ options?.classList.add('hidden');
+ if (fileDisplayArea) fileDisplayArea.innerHTML = '';
+ if (splitInput) splitInput.value = '';
+ if (groupedCheckbox) groupedCheckbox.checked = false;
+ if (pagesPerDocInput) pagesPerDocInput.value = '2';
+ if (warning) {
+ warning.classList.add('hidden');
+ warning.textContent = '';
+ }
+ if (summary) summary.textContent = '';
+ toggleGroupedOptions();
+}
diff --git a/src/js/logic/duplicate-organize.ts b/src/js/logic/duplicate-organize.ts
index cfffe86a..be57fb51 100644
--- a/src/js/logic/duplicate-organize.ts
+++ b/src/js/logic/duplicate-organize.ts
@@ -10,11 +10,7 @@ import Sortable from 'sortablejs';
import { icons, createIcons } from 'lucide';
import { PDFDocument as PDFLibDocument } from 'pdf-lib';
import * as pdfjsLib from 'pdfjs-dist';
-
-pdfjsLib.GlobalWorkerOptions.workerSrc = new URL(
- 'pdfjs-dist/build/pdf.worker.min.mjs',
- import.meta.url
-).toString();
+import '../utils/setup-pdf-worker.js';
const duplicateOrganizeState: {
sortableInstances: { pageGrid?: Sortable };
diff --git a/src/js/logic/edit-pdf-page.ts b/src/js/logic/edit-pdf-page.ts
index 3ce15122..86927cf5 100644
--- a/src/js/logic/edit-pdf-page.ts
+++ b/src/js/logic/edit-pdf-page.ts
@@ -5,14 +5,55 @@ import { formatBytes, downloadFile } from '../utils/helpers.js';
import { makeUniqueFileKey } from '../utils/deduplicate-filename.js';
import { batchDecryptIfNeeded } from '../utils/password-prompt.js';
import { getEditorDisabledCategories } from '../utils/disabled-tools.js';
+import { editorFontFallback } from '../config/editor-fonts.js';
+import { needsFontEmbedding } from '../utils/freetext-script.js';
const embedPdfWasmUrl = new URL(
- 'embedpdf-snippet/dist/pdfium.wasm',
+ 'bentopdf-pdfium/editcore.wasm',
import.meta.url
).href;
-import type { EmbedPdfContainer } from 'embedpdf-snippet';
-import type { DocManagerPlugin } from '@/types';
+import type { EmbedPdfContainer } from 'bentopdf-viewer';
+import type {
+ AnnotationPluginLite,
+ DocManagerPlugin,
+ FreeTextSystemFontAnnotation,
+} from '@/types';
+
+const FREETEXT_SUBTYPE = 3;
+
+function collectAllFreeTexts(
+ annotationPlugin: AnnotationPluginLite | null
+): FreeTextSystemFontAnnotation[] {
+ if (!annotationPlugin) return [];
+ try {
+ const state = annotationPlugin.getState();
+ const out: FreeTextSystemFontAnnotation[] = [];
+ for (const tracked of Object.values(state.byUid)) {
+ const obj = tracked.object;
+ if (obj.type !== FREETEXT_SUBTYPE) continue;
+ if (obj.intent === 'FreeTextCallout') continue;
+ if (!obj.id || obj.pageIndex == null || !obj.rect) continue;
+ out.push({
+ id: obj.id,
+ pageIndex: obj.pageIndex,
+ contents: obj.contents ?? '',
+ fontSize: obj.fontSize ?? 12,
+ fontColor: obj.fontColor ?? '#000000',
+ textAlign: obj.textAlign ?? 0,
+ verticalAlign: obj.verticalAlign ?? 0,
+ opacity: obj.opacity ?? 1,
+ backgroundColor: obj.color ?? obj.backgroundColor,
+ rect: obj.rect,
+ fontPostScriptName: obj.fontPostScriptName ?? '',
+ rotation: obj.rotation ?? 0,
+ });
+ }
+ return out;
+ } catch {
+ return [];
+ }
+}
let viewerInstance: EmbedPdfContainer | null = null;
let docManagerPlugin: DocManagerPlugin | null = null;
@@ -135,7 +176,7 @@ async function handleFiles(files: FileList) {
pdfContainer.textContent = '';
pdfWrapper.classList.remove('hidden');
- const { default: EmbedPDF } = await import('embedpdf-snippet');
+ const { default: EmbedPDF } = await import('bentopdf-viewer');
const disabledCategories = getEditorDisabledCategories();
viewerInstance = EmbedPDF.init({
disabledCategories,
@@ -143,6 +184,7 @@ async function handleFiles(files: FileList) {
target: pdfContainer,
worker: true,
wasmUrl: embedPdfWasmUrl,
+ fontFallback: editorFontFallback,
export: {
defaultFileName: firstFile.name,
},
@@ -218,7 +260,50 @@ async function handleFiles(files: FileList) {
try {
const exportPlugin = registry.getPlugin('export').provides();
const arrayBuffer = await exportPlugin.saveAsCopy().toPromise();
- const blob = new Blob([arrayBuffer], { type: 'application/pdf' });
+ let outBytes = new Uint8Array(arrayBuffer);
+ let annotationPlugin: AnnotationPluginLite | null = null;
+ try {
+ annotationPlugin = registry
+ .getPlugin('annotation')
+ .provides() as unknown as AnnotationPluginLite;
+ } catch {
+ annotationPlugin = null;
+ }
+ const allFreeTexts = collectAllFreeTexts(annotationPlugin);
+ let pending = allFreeTexts;
+ if (pending.length > 0) {
+ try {
+ const { flattenFreeTextToPageText } =
+ await import('../utils/freetext-flatten.js');
+ const res = await flattenFreeTextToPageText(outBytes, pending);
+ if (res.flattened > 0) {
+ outBytes = res.bytes;
+ pending = [];
+ }
+ } catch (err) {
+ console.error('Flatten pass failed:', err);
+ }
+ }
+ const customFontAnnots = pending.filter(
+ (a) =>
+ a.fontPostScriptName.trim() !== '' ||
+ needsFontEmbedding(a.contents)
+ );
+ if (customFontAnnots.length > 0) {
+ try {
+ const { embedFreeTextSystemFonts } =
+ await import('../utils/freetext-font-embed.js');
+ outBytes = await embedFreeTextSystemFonts(
+ outBytes,
+ customFontAnnots
+ );
+ } catch (err) {
+ console.error('Font embed pass failed:', err);
+ }
+ }
+ const blob = new Blob([new Uint8Array(outBytes)], {
+ type: 'application/pdf',
+ });
downloadFile(blob, currentFileName);
} catch (err) {
console.error('Error downloading PDF:', err);
diff --git a/src/js/logic/edit-pdf-text-dock.ts b/src/js/logic/edit-pdf-text-dock.ts
new file mode 100644
index 00000000..f2a05b40
--- /dev/null
+++ b/src/js/logic/edit-pdf-text-dock.ts
@@ -0,0 +1,840 @@
+type SectionSpec = { id: string; title: string; icon: string };
+type RangeLink = { range: HTMLInputElement; num: HTMLInputElement };
+
+const TEXT_SECTIONS: SectionSpec[] = [
+ { id: 'font', title: 'Font', icon: 'ph-text-aa' },
+ { id: 'size', title: 'Font Size', icon: 'ph-text-t' },
+ { id: 'color', title: 'Text Color', icon: 'ph-palette' },
+ { id: 'align', title: 'Alignment', icon: 'ph-text-align-left' },
+ { id: 'spacing', title: 'Spacing', icon: 'ph-arrows-out-line-vertical' },
+ { id: 'lists', title: 'Lists', icon: 'ph-list-bullets' },
+ { id: 'more', title: 'More', icon: 'ph-dots-three' },
+];
+const SCOPE_SECTION: SectionSpec = {
+ id: 'scope',
+ title: 'Edit Scope',
+ icon: 'ph-pencil-simple',
+};
+const STROKE_SECTION: SectionSpec = {
+ id: 'stroke',
+ title: 'Path Stroke',
+ icon: 'ph-path',
+};
+const MULTI_SECTION: SectionSpec = {
+ id: 'alignMulti',
+ title: 'Align Objects',
+ icon: 'ph-align-left',
+};
+
+const OBJ_BTN_IDS = [
+ 'rotL',
+ 'rotR',
+ 'flipH',
+ 'flipV',
+ 'replImg',
+ 'extEdit',
+ 'altText',
+ 'front',
+ 'back',
+ 'dupe',
+];
+
+const SLIDER_FIELDS: [string, string][] = [
+ ['fLine', 'Line'],
+ ['fPara', 'Paragraph'],
+ ['fChar', 'Character'],
+ ['fWordSp', 'Word'],
+];
+const MORE_FIELDS: [string, string][] = [
+ ['fHScale', 'H-Scale %'],
+ ['fFirstInd', 'First Indent'],
+ ['fHangInd', 'Hanging Indent'],
+];
+
+const q = (id: string): HTMLElement | null => document.getElementById(id);
+
+const ph = (name: string): HTMLElement => {
+ const i = document.createElement('i');
+ i.className = `ph ${name}`;
+ return i;
+};
+
+const div = (cls: string): HTMLDivElement => {
+ const d = document.createElement('div');
+ d.className = cls;
+ return d;
+};
+
+const mkBtn = (cls: string, label: string): HTMLButtonElement => {
+ const b = document.createElement('button');
+ b.type = 'button';
+ b.className = cls;
+ b.title = label;
+ b.setAttribute('aria-label', label);
+ b.addEventListener('mousedown', (e) => e.preventDefault());
+ return b;
+};
+
+const rowOf = (el: Element | null): HTMLElement | null => {
+ const r = el?.closest('.row');
+ return r instanceof HTMLElement ? r : null;
+};
+
+const stepperOf = (el: Element | null): HTMLElement | null => {
+ const s = el?.closest('.stepper');
+ return s instanceof HTMLElement ? s : null;
+};
+
+const numInput = (id: string): HTMLInputElement | null => {
+ const el = q(id);
+ return el instanceof HTMLInputElement ? el : null;
+};
+
+function setupPagePill(): void {
+ const save = q('save');
+ const pageLabel = q('pageLabel');
+ const app = q('text-editor-app');
+ if (!save || !pageLabel || !app || !save.parentElement) return;
+ const pill = mkBtn('dpagepill', 'Go to page');
+ pill.textContent = '– / –';
+ save.parentElement.insertBefore(pill, save);
+ const syncPill = () => {
+ pill.textContent = pageLabel.textContent ?? '';
+ };
+ new MutationObserver(syncPill).observe(pageLabel, {
+ childList: true,
+ characterData: true,
+ subtree: true,
+ });
+ syncPill();
+ const parse = () => {
+ const parts = (pageLabel.textContent ?? '').split('/');
+ return {
+ current: parseInt(parts[0] ?? '', 10),
+ total: parseInt(parts[1] ?? '', 10),
+ };
+ };
+ let menu: HTMLDivElement | null = null;
+ const closeMenu = () => {
+ menu?.remove();
+ menu = null;
+ };
+ pill.addEventListener('click', () => {
+ if (menu) {
+ closeMenu();
+ return;
+ }
+ menu = div('ecmenu dpagemenu');
+ const rect = pill.getBoundingClientRect();
+ menu.style.top = `${rect.bottom + 6}px`;
+ menu.style.right = '8px';
+ menu.addEventListener('click', (e) => e.stopPropagation());
+ const nav = div('dpagenav');
+ const prevB = mkBtn('dnavbtn', 'Previous page');
+ prevB.appendChild(ph('ph-caret-left'));
+ prevB.addEventListener('click', () => q('prev')?.click());
+ const nextB = mkBtn('dnavbtn', 'Next page');
+ nextB.appendChild(ph('ph-caret-right'));
+ nextB.addEventListener('click', () => q('next')?.click());
+ const inp = document.createElement('input');
+ inp.type = 'number';
+ inp.min = '1';
+ inp.step = '1';
+ inp.className = 'dpageinp';
+ inp.setAttribute('aria-label', 'Page number');
+ const st = parse();
+ if (Number.isFinite(st.current)) inp.value = String(st.current);
+ if (Number.isFinite(st.total)) inp.max = String(st.total);
+ const go = () => {
+ const p = parse();
+ const target = parseInt(inp.value, 10);
+ if (
+ !Number.isFinite(p.current) ||
+ !Number.isFinite(p.total) ||
+ !Number.isFinite(target)
+ )
+ return;
+ const clamped = Math.min(Math.max(target, 1), p.total);
+ const delta = clamped - p.current;
+ const btn = q(delta > 0 ? 'next' : 'prev');
+ for (let i = 0; i < Math.abs(delta); i++) btn?.click();
+ closeMenu();
+ };
+ inp.addEventListener('keydown', (e) => {
+ if (e.key !== 'Enter') return;
+ e.preventDefault();
+ go();
+ });
+ const goBtn = mkBtn('dnavbtn dgo', 'Go to page');
+ goBtn.textContent = 'Go';
+ goBtn.addEventListener('click', go);
+ nav.append(prevB, inp, goBtn, nextB);
+ menu.appendChild(nav);
+ menu.appendChild(document.createElement('hr'));
+ const props = mkBtn('ecmenu-item', 'Document properties');
+ const propLabel = document.createElement('span');
+ propLabel.textContent = 'Document properties';
+ props.append(ph('ph-info'), propLabel);
+ props.addEventListener('click', () => {
+ closeMenu();
+ q('docInfoBtn')?.click();
+ });
+ menu.appendChild(props);
+ app.appendChild(menu);
+ const dismiss = (e: MouseEvent) => {
+ if (e.target === pill || pill.contains(e.target as Node)) return;
+ closeMenu();
+ window.removeEventListener('click', dismiss);
+ };
+ window.setTimeout(() => window.addEventListener('click', dismiss), 0);
+ });
+}
+
+export function setupFormatDock(): void {
+ const inspector = q('inspector');
+ const app = q('text-editor-app');
+ const contextbar = q('contextbar');
+ const alignTools = q('alignTools');
+ const pStrokeRow = q('pStrokeRow');
+ const scopeSel = q('editScope');
+ const editBtn =
+ document.querySelector('[data-tool="edit"]');
+ if (
+ !inspector ||
+ !app ||
+ !contextbar ||
+ !alignTools ||
+ !pStrokeRow ||
+ !editBtn ||
+ !(scopeSel instanceof HTMLSelectElement)
+ )
+ return;
+
+ inspector.classList.remove('collapsed');
+ const rangeLinks: RangeLink[] = [];
+
+ const panel = div('dockpanel');
+ const clip = div('dockclip');
+ const body = div('dockbody');
+ const head = div('dockhead');
+ const dtitle = div('dtitle');
+ const dticon = document.createElement('span');
+ const dtext = document.createElement('span');
+ dtitle.append(dticon, dtext);
+ const dclose = mkBtn('dclose', 'Close panel');
+ dclose.appendChild(ph('ph-x'));
+ head.append(dtitle, dclose);
+ const slotsWrap = div('dockslots');
+ body.append(head, slotsWrap);
+ clip.appendChild(body);
+ panel.appendChild(clip);
+
+ const rail = div('dockrail');
+ const chipsWrap = div('dockchips');
+ const acts = div('dockacts');
+ const delChip = mkBtn('dchip ddel', 'Delete');
+ delChip.appendChild(ph('ph-trash'));
+ delChip.addEventListener('click', () => q('del')?.click());
+ acts.appendChild(delChip);
+ rail.append(chipsWrap, acts);
+ inspector.append(panel, rail);
+
+ const slots = new Map();
+ const mkSlot = (id: string): HTMLElement => {
+ const s = div('dslot');
+ s.dataset.sec = id;
+ slotsWrap.appendChild(s);
+ slots.set(id, s);
+ return s;
+ };
+
+ const sliderRow = (id: string, label: string): HTMLElement | null => {
+ const num = numInput(id);
+ if (!num) return null;
+ const stepper = stepperOf(num);
+ const row = div('dfield');
+ const lab = div('dlab');
+ lab.textContent = label;
+ const range = document.createElement('input');
+ range.type = 'range';
+ range.min = num.min;
+ range.max = num.max;
+ range.step = num.step;
+ range.value = num.value || num.min;
+ range.setAttribute('aria-label', label);
+ range.addEventListener('pointerdown', () => {
+ range.dataset.drag = '1';
+ });
+ range.addEventListener('pointerup', () => {
+ delete range.dataset.drag;
+ });
+ range.addEventListener('input', () => {
+ num.value = range.value;
+ });
+ range.addEventListener('change', () => {
+ delete range.dataset.drag;
+ num.value = range.value;
+ num.dispatchEvent(new Event('change'));
+ });
+ rangeLinks.push({ range, num });
+ row.append(lab, range, stepper ?? num);
+ return row;
+ };
+
+ const sizeSlot = mkSlot('size');
+ const fSize = numInput('fSize');
+ if (fSize) {
+ const sizer = div('dsizer');
+ const step = (dir: -1 | 1, icon: string, label: string) => {
+ const b = mkBtn('dstep', label);
+ b.appendChild(ph(icon));
+ b.addEventListener('click', () => {
+ if (fSize.disabled || fSize.value === '') return;
+ if (dir > 0) fSize.stepUp();
+ else fSize.stepDown();
+ fSize.dispatchEvent(new Event('change'));
+ });
+ return b;
+ };
+ const minus = step(-1, 'ph-minus', 'Decrease size');
+ const plus = step(1, 'ph-plus', 'Increase size');
+ const host = stepperOf(fSize) ?? fSize;
+ sizer.append(minus, host, plus);
+ sizeSlot.appendChild(sizer);
+ }
+
+ const colorSlot = mkSlot('color');
+ const colorRow = rowOf(q('fColor'));
+ if (colorRow) colorSlot.appendChild(colorRow);
+
+ const alignSlot = mkSlot('align');
+ const alignRow = rowOf(q('fAlign'));
+ if (alignRow) alignSlot.appendChild(alignRow);
+ const dirLab = div('dlab dsublab');
+ dirLab.textContent = 'Direction';
+ alignSlot.appendChild(dirLab);
+ const dirRow = rowOf(q('fDir'));
+ if (dirRow) alignSlot.appendChild(dirRow);
+
+ const spacingSlot = mkSlot('spacing');
+ for (const [id, label] of SLIDER_FIELDS) {
+ const row = sliderRow(id, label);
+ if (row) spacingSlot.appendChild(row);
+ }
+
+ const listsSlot = mkSlot('lists');
+ const listsRow = rowOf(q('fList'));
+ if (listsRow) listsSlot.appendChild(listsRow);
+
+ const moreSlot = mkSlot('more');
+ const outLab = div('dlab dsublab');
+ outLab.textContent = 'Outline';
+ moreSlot.appendChild(outLab);
+ const outlineRow = rowOf(q('fStrokeColor'));
+ if (outlineRow) moreSlot.appendChild(outlineRow);
+ for (const [id, label] of MORE_FIELDS) {
+ const row = sliderRow(id, label);
+ if (row) moreSlot.appendChild(row);
+ }
+
+ const strokeSlot = mkSlot('stroke');
+ strokeSlot.appendChild(pStrokeRow);
+
+ const multiSlot = mkSlot('alignMulti');
+ multiSlot.appendChild(alignTools);
+
+ const scopeSlot = mkSlot('scope');
+ const scopeWrap = div('dscope');
+ scopeSlot.appendChild(scopeWrap);
+ let picked = new Set(['all']);
+ const scopeDefs: [string, string][] = [
+ ['all', 'All'],
+ ['text', 'Text'],
+ ['image', 'Images'],
+ ['shape', 'Shapes'],
+ ];
+ const renderScope = () => {
+ scopeWrap.replaceChildren();
+ for (const [key, label] of scopeDefs) {
+ const b = mkBtn(`dscopebtn${picked.has(key) ? ' on' : ''}`, label);
+ b.textContent = label;
+ b.addEventListener('click', () => {
+ if (key === 'all') picked = new Set(['all']);
+ else {
+ if (picked.has('all')) picked.clear();
+ if (picked.has(key)) picked.delete(key);
+ else picked.add(key);
+ if (picked.size === 0 || picked.size === 3) picked = new Set(['all']);
+ }
+ applyScope();
+ });
+ scopeWrap.appendChild(b);
+ }
+ };
+ const applyScope = () => {
+ const value = [...picked].join(',');
+ if (!scopeSel.querySelector(`option[value="${value}"]`)) {
+ const o = document.createElement('option');
+ o.value = value;
+ scopeSel.appendChild(o);
+ }
+ scopeSel.value = value;
+ scopeSel.dispatchEvent(new Event('change'));
+ renderScope();
+ };
+ renderScope();
+
+ let activeSec: string | null = null;
+ let syncTimer = 0;
+
+ const stopSync = () => {
+ window.clearInterval(syncTimer);
+ syncTimer = 0;
+ };
+ const startSync = () => {
+ stopSync();
+ syncTimer = window.setInterval(() => {
+ for (const l of rangeLinks) {
+ if (l.range.dataset.drag) continue;
+ if (l.num.value !== '' && l.range.value !== l.num.value)
+ l.range.value = l.num.value;
+ }
+ }, 400);
+ };
+ const syncRangesNow = () => {
+ for (const l of rangeLinks) {
+ if (l.num.value !== '') l.range.value = l.num.value;
+ }
+ };
+
+ const setHead = (spec: SectionSpec) => {
+ dticon.replaceChildren(ph(spec.icon));
+ dtext.textContent = spec.title;
+ };
+ const showSlot = (id: string) => {
+ for (const [k, s] of slots) s.classList.toggle('act', k === id);
+ };
+
+ const closePanel = () => {
+ panel.classList.remove('open');
+ activeSec = null;
+ stopSync();
+ markActive();
+ };
+
+ const dismissKeyboard = (): void => {
+ if (!window.matchMedia('(pointer: coarse)').matches) return;
+ const a = document.activeElement;
+ if (a instanceof HTMLElement && a.isContentEditable) a.blur();
+ };
+
+ const openSec = (spec: SectionSpec) => {
+ dismissKeyboard();
+ const isOpen = panel.classList.contains('open');
+ if (isOpen && activeSec === spec.id) {
+ closePanel();
+ return;
+ }
+ if (isOpen) {
+ body.classList.add('fading');
+ window.setTimeout(() => {
+ setHead(spec);
+ showSlot(spec.id);
+ body.classList.remove('fading');
+ }, 120);
+ } else {
+ setHead(spec);
+ showSlot(spec.id);
+ panel.classList.add('open');
+ }
+ activeSec = spec.id;
+ syncRangesNow();
+ startSync();
+ markActive();
+ };
+ dclose.addEventListener('click', closePanel);
+
+ const dim = div('ddim');
+ const sheet = div('dsheet');
+ const grab = div('dgrab');
+ grab.appendChild(document.createElement('span'));
+ const shead = div('dsheethead');
+ shead.append(ph('ph-text-aa'), document.createTextNode(' Select Font'));
+ const fontListEl = div('dfontlist');
+ sheet.append(grab, shead, fontListEl);
+ inspector.append(dim, sheet);
+
+ let sheetOpen = false;
+ const rebuildFonts = () => {
+ const fam = q('fFamily');
+ if (!(fam instanceof HTMLSelectElement)) return;
+ fontListEl.replaceChildren();
+ const current = fam.value;
+ for (const opt of Array.from(fam.options)) {
+ const b = mkBtn(`dfrow${opt.value === current ? ' on' : ''}`, opt.value);
+ b.style.fontFamily = `"${opt.value.replace(/"/g, '')}", sans-serif`;
+ const name = document.createElement('span');
+ name.textContent = opt.value;
+ const chk = ph('ph-check');
+ chk.classList.add('dfchk');
+ b.append(name, chk);
+ b.addEventListener('click', () => {
+ fam.value = opt.value;
+ fam.dispatchEvent(new Event('change'));
+ rebuildFonts();
+ window.setTimeout(closeSheet, 180);
+ });
+ fontListEl.appendChild(b);
+ }
+ if ('queryLocalFonts' in window) {
+ fontListEl.appendChild(div('ddivider'));
+ const sys = mkBtn('dfrow dfsys', 'Load system fonts');
+ const label = document.createElement('span');
+ label.textContent = 'Load system fonts…';
+ sys.appendChild(label);
+ sys.addEventListener('click', () => q('sysFonts')?.click());
+ fontListEl.appendChild(sys);
+ }
+ };
+ const openSheet = () => {
+ dismissKeyboard();
+ rebuildFonts();
+ sheetOpen = true;
+ dim.classList.add('show');
+ sheet.classList.add('open');
+ markActive();
+ };
+ const closeSheet = () => {
+ if (!sheetOpen) return;
+ sheetOpen = false;
+ dim.classList.remove('show');
+ sheet.classList.remove('open');
+ sheet.style.transform = '';
+ markActive();
+ };
+ dim.addEventListener('click', closeSheet);
+
+ let dragY: number | null = null;
+ grab.addEventListener('pointerdown', (e) => {
+ dragY = e.clientY;
+ sheet.classList.add('drag');
+ grab.setPointerCapture(e.pointerId);
+ });
+ grab.addEventListener('pointermove', (e) => {
+ if (dragY === null) return;
+ const dy = Math.max(0, e.clientY - dragY);
+ sheet.style.transform = `translateY(${dy}px)`;
+ });
+ const endDrag = (e: PointerEvent) => {
+ if (dragY === null) return;
+ const dy = Math.max(0, e.clientY - dragY);
+ dragY = null;
+ sheet.classList.remove('drag');
+ sheet.style.transform = '';
+ if (dy > 90) closeSheet();
+ };
+ grab.addEventListener('pointerup', endDrag);
+ grab.addEventListener('pointercancel', endDrag);
+
+ const famSel = q('fFamily');
+ if (famSel) {
+ new MutationObserver(() => {
+ if (sheetOpen) rebuildFonts();
+ }).observe(famSel, { childList: true });
+ }
+
+ const visibleObjBtns = (): HTMLButtonElement[] => {
+ const out: HTMLButtonElement[] = [];
+ for (const id of OBJ_BTN_IDS) {
+ const b = q(id);
+ if (b instanceof HTMLButtonElement && !b.hidden) out.push(b);
+ }
+ return out;
+ };
+
+ const computeMode = (): 'text' | 'object' | 'idle' => {
+ if (!inspector.hasAttribute('disabled')) return 'text';
+ if (visibleObjBtns().length || !alignTools.hidden) return 'object';
+ return 'idle';
+ };
+
+ const secChip = (spec: SectionSpec): HTMLButtonElement => {
+ const b = mkBtn('dchip', spec.title);
+ b.dataset.sec = spec.id;
+ b.appendChild(ph(spec.icon));
+ b.addEventListener('click', () => {
+ if (spec.id === 'font') {
+ if (sheetOpen) closeSheet();
+ else openSheet();
+ } else openSec(spec);
+ });
+ return b;
+ };
+
+ const proxyChip = (src: HTMLButtonElement): HTMLButtonElement => {
+ const b = mkBtn('dchip', src.title || 'Action');
+ b.dataset.proxy = src.id;
+ const icon = src.querySelector('i');
+ b.appendChild(
+ icon ? (icon.cloneNode(true) as HTMLElement) : ph('ph-circle')
+ );
+ b.addEventListener('click', () => src.click());
+ return b;
+ };
+
+ const toolChip = (
+ id: string,
+ icon: string,
+ label: string,
+ onTap: () => void
+ ): HTMLButtonElement => {
+ const b = mkBtn('dchip', label);
+ b.dataset.tool = id;
+ b.appendChild(ph(icon));
+ b.addEventListener('click', onTap);
+ return b;
+ };
+
+ let lastMode: 'text' | 'object' | 'idle' | null = null;
+
+ const togglesRow = q('fToggles');
+ const toggleChip = (src: HTMLButtonElement): HTMLButtonElement => {
+ const b = mkBtn('dchip', src.title || 'Toggle style');
+ b.dataset.tog = src.dataset.t ?? '';
+ const icon = src.querySelector('i');
+ b.appendChild(
+ icon ? (icon.cloneNode(true) as HTMLElement) : ph('ph-circle')
+ );
+ b.addEventListener('click', () => src.click());
+ return b;
+ };
+
+ const buildRail = () => {
+ const mode = computeMode();
+ chipsWrap.replaceChildren();
+ if (mode !== 'idle') {
+ chipsWrap.appendChild(
+ toolChip('done', 'ph-check', 'Done — back to tools', () => {
+ closePanel();
+ closeSheet();
+ editBtn.click();
+ })
+ );
+ }
+ if (mode === 'text') {
+ for (const s of TEXT_SECTIONS) {
+ chipsWrap.appendChild(secChip(s));
+ if (s.id === 'color' && togglesRow) {
+ for (const t of togglesRow.children) {
+ if (t instanceof HTMLButtonElement)
+ chipsWrap.appendChild(toggleChip(t));
+ }
+ }
+ }
+ } else if (mode === 'idle') {
+ chipsWrap.appendChild(
+ toolChip('edit', 'ph-pencil-simple', 'Edit tool', () => {
+ if (editBtn.classList.contains('on')) openSec(SCOPE_SECTION);
+ else {
+ closePanel();
+ applyScope();
+ }
+ })
+ );
+ chipsWrap.appendChild(
+ toolChip('addText', 'ph-textbox', 'Add a text box', () => {
+ closePanel();
+ document
+ .querySelector('[data-tool="addText"]')
+ ?.click();
+ })
+ );
+ chipsWrap.appendChild(
+ toolChip('addImage', 'ph-image', 'Add an image', () => {
+ closePanel();
+ q('addImage')?.click();
+ })
+ );
+ chipsWrap.appendChild(
+ toolChip('find', 'ph-magnifying-glass', 'Find & replace', () => {
+ closePanel();
+ q('find')?.click();
+ })
+ );
+ }
+ if (!alignTools.hidden) chipsWrap.appendChild(secChip(MULTI_SECTION));
+ if (!pStrokeRow.hidden) chipsWrap.appendChild(secChip(STROKE_SECTION));
+ for (const b of visibleObjBtns()) chipsWrap.appendChild(proxyChip(b));
+ const delBtn = q('del');
+ acts.hidden = !(delBtn instanceof HTMLButtonElement) || delBtn.hidden;
+ markActive();
+ };
+
+ function markActive(): void {
+ const editOn = editBtn.classList.contains('on');
+ for (const c of chipsWrap.children) {
+ if (!(c instanceof HTMLElement)) continue;
+ if (c.dataset.sec) {
+ c.classList.toggle(
+ 'on',
+ c.dataset.sec === 'font' ? sheetOpen : c.dataset.sec === activeSec
+ );
+ } else if (c.dataset.tog) {
+ const src = togglesRow?.querySelector(`[data-t="${c.dataset.tog}"]`);
+ c.classList.toggle('on', !!src?.classList.contains('on'));
+ } else if (c.dataset.tool === 'edit') {
+ c.classList.toggle('on', editOn || activeSec === 'scope');
+ } else if (c.dataset.tool === 'addText') {
+ c.classList.toggle('on', !editOn);
+ }
+ }
+ }
+
+ let raf = 0;
+ const refresh = () => {
+ cancelAnimationFrame(raf);
+ raf = requestAnimationFrame(() => {
+ const mode = computeMode();
+ if (mode !== lastMode) {
+ closePanel();
+ closeSheet();
+ lastMode = mode;
+ }
+ buildRail();
+ if (activeSec && !chipsWrap.querySelector(`[data-sec="${activeSec}"]`))
+ closePanel();
+ });
+ };
+
+ new MutationObserver(refresh).observe(inspector, {
+ attributes: true,
+ attributeFilter: ['disabled', 'hidden'],
+ });
+ new MutationObserver(refresh).observe(contextbar, {
+ attributes: true,
+ subtree: true,
+ attributeFilter: ['hidden'],
+ });
+ new MutationObserver(refresh).observe(alignTools, {
+ attributes: true,
+ attributeFilter: ['hidden'],
+ });
+ new MutationObserver(refresh).observe(pStrokeRow, {
+ attributes: true,
+ attributeFilter: ['hidden'],
+ });
+ new MutationObserver(() => markActive()).observe(editBtn, {
+ attributes: true,
+ attributeFilter: ['class'],
+ });
+ if (togglesRow) {
+ new MutationObserver(() => markActive()).observe(togglesRow, {
+ attributes: true,
+ subtree: true,
+ attributeFilter: ['class'],
+ });
+ }
+
+ const vv = window.visualViewport;
+ if (vv) {
+ const updateOffset = () => {
+ const off = Math.max(0, window.innerHeight - vv.height - vv.offsetTop);
+ app.style.setProperty('--eckb', `${Math.round(off)}px`);
+ };
+ vv.addEventListener('resize', updateOffset);
+ vv.addEventListener('scroll', updateOffset);
+ updateOffset();
+ }
+
+ const publishDockHeight = () => {
+ app.style.setProperty(
+ '--ecdock',
+ `${Math.round(inspector.getBoundingClientRect().height)}px`
+ );
+ };
+ new ResizeObserver(publishDockHeight).observe(inspector);
+ publishDockHeight();
+
+ setupPagePill();
+ refresh();
+}
+
+export function setupFindSheet(): void {
+ const findbar = q('findbar');
+ if (!findbar) return;
+ const closeFind = () => q('findDone')?.click();
+ const grab = div('ecgrab');
+ grab.appendChild(document.createElement('span'));
+ findbar.prepend(grab);
+ let startY = 0;
+ let lastY = 0;
+ let lastT = 0;
+ let vel = 0;
+ let mode: 'idle' | 'undecided' | 'drag' | 'scroll' = 'idle';
+ const onStart = (e: TouchEvent) => {
+ if (e.touches.length !== 1) return;
+ startY = lastY = e.touches[0].clientY;
+ lastT = performance.now();
+ vel = 0;
+ const onGrab = (e.target as HTMLElement | null)?.closest('.ecgrab');
+ mode = onGrab ? 'drag' : 'undecided';
+ };
+ const onMove = (e: TouchEvent) => {
+ if (mode === 'idle' || mode === 'scroll') return;
+ const y = e.touches[0].clientY;
+ const dy = y - startY;
+ if (mode === 'undecided') {
+ if (Math.abs(dy) < 6) return;
+ if (dy > 0 && findbar.scrollTop <= 0) mode = 'drag';
+ else {
+ mode = 'scroll';
+ return;
+ }
+ }
+ e.preventDefault();
+ const now = performance.now();
+ vel = (y - lastY) / Math.max(1, now - lastT);
+ lastY = y;
+ lastT = now;
+ findbar.style.transform = `translateY(${Math.max(0, y - startY)}px)`;
+ };
+ const onEnd = () => {
+ if (mode !== 'drag') {
+ mode = 'idle';
+ return;
+ }
+ mode = 'idle';
+ const dy = Math.max(0, lastY - startY);
+ findbar.style.transform = '';
+ if (dy > 90 || vel > 0.55) {
+ const anim = findbar.animate(
+ [
+ { transform: `translateY(${dy}px)` },
+ { transform: 'translateY(110%)' },
+ ],
+ { duration: 160, easing: 'ease-in' }
+ );
+ anim.onfinish = () => closeFind();
+ } else if (dy > 0) {
+ findbar.animate(
+ [{ transform: `translateY(${dy}px)` }, { transform: 'none' }],
+ { duration: 160, easing: 'ease-out' }
+ );
+ }
+ };
+ findbar.addEventListener('touchstart', onStart, { passive: true });
+ findbar.addEventListener('touchmove', onMove, { passive: false });
+ findbar.addEventListener('touchend', onEnd);
+ findbar.addEventListener('touchcancel', onEnd);
+ let findWasOpen = !findbar.hidden;
+ new MutationObserver(() => {
+ const open = !findbar.hidden;
+ if (open && !findWasOpen) {
+ findbar.animate(
+ [{ transform: 'translateY(40px)' }, { transform: 'none' }],
+ { duration: 200, easing: 'ease-out' }
+ );
+ }
+ findWasOpen = open;
+ }).observe(findbar, { attributes: true, attributeFilter: ['hidden'] });
+}
diff --git a/src/js/logic/edit-pdf-text-page.ts b/src/js/logic/edit-pdf-text-page.ts
new file mode 100644
index 00000000..02f75e7c
--- /dev/null
+++ b/src/js/logic/edit-pdf-text-page.ts
@@ -0,0 +1,891 @@
+import '@phosphor-icons/web/regular';
+import { createIcons, icons } from 'lucide';
+import { showAlert, showLoader, hideLoader } from '../ui.js';
+import { t } from '../i18n/i18n';
+import { batchDecryptIfNeeded } from '../utils/password-prompt.js';
+import { setupFormatDock, setupFindSheet } from './edit-pdf-text-dock';
+
+interface DocDescription {
+ meta: { name: string; size: number; source?: string } | null;
+ info: { pages: number; signatures: number } | null;
+ pdfa: boolean;
+ fields: Record;
+ pageSize: { w: number; h: number } | null;
+}
+
+function buildMenu(anchor: HTMLElement, cls: string): HTMLDivElement {
+ const menu = document.createElement('div');
+ menu.className = `ecmenu ${cls}`;
+ const rect = anchor.getBoundingClientRect();
+ const app = document.getElementById('text-editor-app');
+ if (!app) return menu;
+ if (rect.top > window.innerHeight / 2) {
+ menu.style.bottom = `${window.innerHeight - rect.top + 6}px`;
+ } else {
+ menu.style.top = `${rect.bottom + 6}px`;
+ }
+ menu.style.left = `${Math.max(8, rect.left)}px`;
+ menu.addEventListener('click', (e) => e.stopPropagation());
+ app.appendChild(menu);
+ const dismiss = (e: MouseEvent) => {
+ if (e.target === anchor || anchor.contains(e.target as Node)) return;
+ menu.remove();
+ window.removeEventListener('click', dismiss);
+ };
+ window.setTimeout(() => window.addEventListener('click', dismiss), 0);
+ return menu;
+}
+
+function menuItem(
+ label: string,
+ checked: boolean,
+ onPick: () => void,
+ icon?: string
+): HTMLButtonElement {
+ const item = document.createElement('button');
+ item.className = 'ecmenu-item';
+ const mark = document.createElement('i');
+ mark.className = checked ? 'ph ph-check' : 'ecmenu-blank';
+ const text = document.createElement('span');
+ text.textContent = label;
+ if (icon) {
+ const glyph = document.createElement('i');
+ glyph.className = `ph ${icon} ecmenu-icon`;
+ item.append(mark, glyph, text);
+ } else {
+ item.append(mark, text);
+ }
+ item.addEventListener('click', onPick);
+ return item;
+}
+
+const SCOPES: [string, string, string][] = [
+ ['text', 'Edit Text', 'ph-text-t'],
+ ['image', 'Edit Images', 'ph-image'],
+ ['shape', 'Edit Shapes', 'ph-shapes'],
+];
+
+function setupEditMenu() {
+ const editBtn = document.querySelector(
+ '#tools [data-tool="edit"]'
+ );
+ const label = document.getElementById('editLabel');
+ const scopeSel = document.getElementById(
+ 'editScope'
+ ) as HTMLSelectElement | null;
+ if (!editBtn || !label || !scopeSel) return;
+ let picked = new Set(['all']);
+ const apply = () => {
+ const value = [...picked].join(',');
+ if (!scopeSel.querySelector(`option[value="${value}"]`)) {
+ const opt = document.createElement('option');
+ opt.value = value;
+ scopeSel.appendChild(opt);
+ }
+ scopeSel.value = value;
+ scopeSel.dispatchEvent(new Event('change'));
+ label.textContent = picked.has('all')
+ ? 'Edit All'
+ : 'Edit ' +
+ SCOPES.filter(([k]) => picked.has(k))
+ .map(([, l]) => l.replace('Edit ', ''))
+ .join(' + ');
+ };
+ let open: HTMLDivElement | null = null;
+ const caret = editBtn.querySelector('.editcaret');
+ const markOpen = (isOpen: boolean) => caret?.classList.toggle('up', isOpen);
+ editBtn.addEventListener('click', () => {
+ if (open?.isConnected) {
+ open.remove();
+ open = null;
+ markOpen(false);
+ return;
+ }
+ const menu = buildMenu(editBtn, 'editmenu');
+ open = menu;
+ markOpen(true);
+ const watch = new MutationObserver(() => {
+ if (!menu.isConnected) {
+ markOpen(false);
+ watch.disconnect();
+ }
+ });
+ const host = menu.parentElement;
+ if (host) watch.observe(host, { childList: true });
+ const rebuild = () => {
+ menu.replaceChildren();
+ menu.appendChild(
+ menuItem(
+ 'Edit All',
+ picked.has('all'),
+ () => {
+ picked = new Set(['all']);
+ apply();
+ rebuild();
+ },
+ 'ph-selection-all'
+ )
+ );
+ for (const [key, lbl, glyph] of SCOPES) {
+ menu.appendChild(
+ menuItem(
+ lbl,
+ picked.has(key),
+ () => {
+ if (picked.has('all')) picked.clear();
+ if (picked.has(key)) picked.delete(key);
+ else picked.add(key);
+ if (picked.size === 0 || picked.size === SCOPES.length)
+ picked = new Set(['all']);
+ apply();
+ rebuild();
+ },
+ glyph
+ )
+ );
+ }
+ };
+ rebuild();
+ });
+}
+
+const ZOOM_PRESETS = [50, 75, 100, 125, 150, 200, 400];
+
+function zoomRow(
+ label: string,
+ active: boolean,
+ icon: string | null,
+ onPick: () => void
+) {
+ const item = document.createElement('button');
+ item.className = `ecmenu-item${active ? ' active' : ''}`;
+ if (icon) {
+ const i = document.createElement('i');
+ i.className = `ph ${icon}`;
+ item.appendChild(i);
+ }
+ const text = document.createElement('span');
+ text.textContent = label;
+ item.appendChild(text);
+ item.addEventListener('click', onPick);
+ return item;
+}
+
+function fitPage() {
+ const stage = document.getElementById('stage');
+ const wrap = document.getElementById('pageWrap');
+ const mod = appModule;
+ if (!stage || !wrap || !mod) return;
+ const z = mod.getZoom();
+ const pw = wrap.offsetWidth / z;
+ const ph = wrap.offsetHeight / z;
+ if (pw <= 0 || ph <= 0) return;
+ mod.setZoom(
+ Math.min((stage.clientWidth - 48) / pw, (stage.clientHeight - 48) / ph)
+ );
+}
+
+function setupZoomMenu() {
+ const btn = document.getElementById('zoomMenuBtn');
+ const zoomLabel = document.getElementById('zoomLabel');
+ if (!btn || !zoomLabel) return;
+ let open: HTMLDivElement | null = null;
+ btn.addEventListener('click', () => {
+ if (open?.isConnected) {
+ open.remove();
+ open = null;
+ return;
+ }
+ const current = parseInt(zoomLabel.textContent ?? '', 10);
+ const menu = buildMenu(btn, 'zoommenu');
+ open = menu;
+ for (const pct of ZOOM_PRESETS) {
+ menu.appendChild(
+ zoomRow(`${pct}%`, current === pct, null, () => {
+ appModule?.setZoom(pct / 100);
+ menu.remove();
+ })
+ );
+ }
+ menu.appendChild(document.createElement('hr'));
+ menu.appendChild(
+ zoomRow('Fit to Page', false, 'ph-arrows-out', () => {
+ fitPage();
+ menu.remove();
+ })
+ );
+ menu.appendChild(
+ zoomRow('Fit to Width', false, 'ph-arrows-out-line-horizontal', () => {
+ document.getElementById('zoomFit')?.click();
+ menu.remove();
+ })
+ );
+ menu.appendChild(document.createElement('hr'));
+ const form = document.createElement('form');
+ form.className = 'zoomcustom';
+ const inp = document.createElement('input');
+ inp.type = 'number';
+ inp.min = '20';
+ inp.max = '600';
+ inp.placeholder = 'Custom Zoom';
+ const pctSpan = document.createElement('span');
+ pctSpan.textContent = '%';
+ form.append(inp, pctSpan);
+ form.addEventListener('submit', (e) => {
+ e.preventDefault();
+ const v = parseFloat(inp.value);
+ if (Number.isFinite(v) && v >= 20 && v <= 600) {
+ appModule?.setZoom(v / 100);
+ menu.remove();
+ }
+ });
+ menu.appendChild(form);
+ window.setTimeout(() => inp.focus(), 0);
+ });
+}
+
+function setupBarCollapse() {
+ const viewbar = document.getElementById('viewbar');
+ const collapse = document.getElementById('vbCollapse');
+ const restore = document.getElementById('vbRestore');
+ if (!viewbar || !collapse || !restore) return;
+ collapse.addEventListener('click', () => {
+ viewbar.setAttribute('hidden', '');
+ restore.removeAttribute('hidden');
+ });
+ restore.addEventListener('click', () => {
+ viewbar.removeAttribute('hidden');
+ restore.setAttribute('hidden', '');
+ });
+}
+
+function formatPdfDate(raw: string): string {
+ const m = raw.match(/D:(\d{4})(\d{2})?(\d{2})?(\d{2})?(\d{2})?/);
+ if (!m) return raw;
+ const [, y, mo, d, h, mi] = m;
+ let out = `${y}`;
+ if (mo) out = `${mo}/${d ?? '01'}/${y}`;
+ if (h) out += `, ${h}:${mi ?? '00'}`;
+ return out;
+}
+
+interface EditorAppModule {
+ openFile: (file: File) => Promise;
+ engineReady: Promise;
+ setZoom: (z: number) => void;
+ getZoom: () => number;
+ getDocDescription: () => DocDescription;
+ setOnSaved: (fn: ((kb: number, fileName: string) => void) | null) => void;
+}
+
+function relocateAddText() {
+ const addTextBtn = document.querySelector(
+ '#tools [data-tool="addText"]'
+ );
+ const editBtn = document.querySelector(
+ '#tools [data-tool="edit"]'
+ );
+ const findBtn = document.getElementById('find');
+ if (!addTextBtn || !editBtn || !findBtn?.parentElement) return;
+ addTextBtn.replaceChildren();
+ const icon = document.createElement('i');
+ icon.className = 'ph ph-text-t';
+ addTextBtn.appendChild(icon);
+ addTextBtn.classList.add('btn', 'icon');
+ addTextBtn.title = 'Add a text box';
+ findBtn.parentElement.insertBefore(addTextBtn, findBtn);
+ const sync = () =>
+ addTextBtn.classList.toggle('on', !editBtn.classList.contains('on'));
+ new MutationObserver(sync).observe(editBtn, {
+ attributes: true,
+ attributeFilter: ['class'],
+ });
+ sync();
+}
+
+function setupDocPanel() {
+ const btn = document.getElementById('docInfoBtn');
+ const app = document.getElementById('text-editor-app');
+ if (!btn || !app) return;
+ let overlay: HTMLDivElement | null = null;
+ const close = () => {
+ overlay?.remove();
+ overlay = null;
+ };
+ btn.addEventListener('click', () => {
+ if (overlay) {
+ close();
+ return;
+ }
+ const desc = appModule?.getDocDescription();
+ if (!desc?.meta) return;
+ overlay = document.createElement('div');
+ overlay.id = 'docModalOverlay';
+ overlay.addEventListener('click', close);
+ const modal = document.createElement('div');
+ modal.id = 'docModal';
+ modal.addEventListener('click', (e) => e.stopPropagation());
+ const head = document.createElement('div');
+ head.className = 'docmodal-head';
+ const title = document.createElement('span');
+ title.textContent = 'Document Properties';
+ const x = document.createElement('button');
+ x.className = 'btn icon';
+ x.title = 'Close';
+ const xi = document.createElement('i');
+ xi.className = 'ph ph-x';
+ x.appendChild(xi);
+ x.addEventListener('click', close);
+ head.append(title, x);
+ modal.appendChild(head);
+ const f = desc.fields ?? {};
+ const inch = (pt: number) => (pt / 72).toFixed(2);
+ const groups: [string, [string, string][]][] = [
+ [
+ 'Description',
+ [
+ ['File', desc.meta.name],
+ ['Title', f.Title || '—'],
+ ['Author', f.Author || '—'],
+ ['Subject', f.Subject || '—'],
+ ['Keywords', f.Keywords || '—'],
+ ['Created', f.CreationDate ? formatPdfDate(f.CreationDate) : '—'],
+ ['Modified', f.ModDate ? formatPdfDate(f.ModDate) : '—'],
+ ['Application', f.Creator || '—'],
+ ],
+ ],
+ [
+ 'Advanced',
+ [
+ ['PDF Producer', f.Producer || '—'],
+ ['File Size', `${(desc.meta.size / 1024).toFixed(0)} KB`],
+ [
+ 'Page Size',
+ desc.pageSize
+ ? `${inch(desc.pageSize.w)} × ${inch(desc.pageSize.h)} in`
+ : '—',
+ ],
+ ['Number of Pages', desc.info?.pages ? String(desc.info.pages) : '—'],
+ ...(desc.info?.signatures
+ ? ([['Signatures', String(desc.info.signatures)]] as [
+ string,
+ string,
+ ][])
+ : []),
+ ...(desc.pdfa
+ ? ([['Conformance', 'PDF/A']] as [string, string][])
+ : []),
+ ],
+ ],
+ ];
+ for (const [name, rows] of groups) {
+ const sect = document.createElement('section');
+ const h = document.createElement('h4');
+ h.textContent = name;
+ sect.appendChild(h);
+ const dl = document.createElement('dl');
+ for (const [k, v] of rows) {
+ const dt = document.createElement('dt');
+ dt.textContent = k;
+ const dd = document.createElement('dd');
+ dd.textContent = v;
+ dl.append(dt, dd);
+ }
+ sect.appendChild(dl);
+ modal.appendChild(sect);
+ }
+ overlay.appendChild(modal);
+ app.appendChild(overlay);
+ });
+}
+
+let appModule: EditorAppModule | null = null;
+
+function closeEditor() {
+ document.getElementById('text-editor-app')?.setAttribute('hidden', '');
+ document.getElementById('uploader')?.classList.remove('hidden');
+ document.getElementById('tool-landing')?.classList.remove('hidden');
+}
+let launching = false;
+
+function fitMobileWidth() {
+ const mod = appModule;
+ const stage = document.getElementById('stage');
+ const wrap = document.getElementById('pageWrap');
+ if (!mod || !stage || !wrap) return;
+ const w = wrap.getBoundingClientRect().width;
+ if (!(w > 0) || !(stage.clientWidth > 0)) return;
+ const target = Math.min(
+ 6,
+ Math.max(0.2, mod.getZoom() * ((stage.clientWidth - 20) / w))
+ );
+ mod.setZoom(target);
+}
+
+async function launchEditor(file: File) {
+ if (launching) return;
+ launching = true;
+ showLoader('Loading PDF Text Editor...');
+ try {
+ hideLoader();
+ const decrypted = await batchDecryptIfNeeded([file]);
+ if (decrypted.length === 0) return;
+ showLoader('Loading PDF Text Editor...');
+ document.getElementById('uploader')?.classList.add('hidden');
+ document.getElementById('tool-landing')?.classList.add('hidden');
+ document.getElementById('text-editor-app')?.removeAttribute('hidden');
+ if (!appModule) {
+ appModule = (await import('../editcore/app.js')) as EditorAppModule;
+ appModule.setOnSaved((kb) => {
+ showAlert(
+ t('common.success'),
+ t('tools:editPdfText.saved', { size: kb }),
+ 'success',
+ () => {
+ closeEditor();
+ }
+ );
+ });
+ relocateAddText();
+ setupDocPanel();
+ setupEditMenu();
+ setupZoomMenu();
+ setupBarCollapse();
+ }
+ const engineOk = await appModule.engineReady;
+ if (!engineOk) {
+ throw new Error('WASM engine failed to initialize');
+ }
+ await appModule.openFile(decrypted[0]);
+ if (window.matchMedia('(max-width: 768px)').matches) fitMobileWidth();
+ } catch (error) {
+ console.error('Error loading PDF Text Editor:', error);
+ showAlert(t('common.error'), t('tools:editPdfText.failedToLoad'));
+ closeEditor();
+ } finally {
+ hideLoader();
+ launching = false;
+ }
+}
+
+function handleFiles(files: FileList) {
+ const pdf = Array.from(files).find(
+ (f) => f.type === 'application/pdf' || /\.pdf$/i.test(f.name)
+ );
+ if (!pdf) {
+ showAlert('Invalid File', 'Please upload a valid PDF file.');
+ return;
+ }
+ void launchEditor(pdf);
+}
+
+function attachSteppers() {
+ const inputs = document.querySelectorAll(
+ '#inspector input.num'
+ );
+ for (const input of inputs) {
+ const wrap = document.createElement('div');
+ wrap.className = 'stepper';
+ input.replaceWith(wrap);
+ const mk = (dir: -1 | 1, icon: string) => {
+ const b = document.createElement('button');
+ b.type = 'button';
+ b.className = 'stepbtn';
+ b.tabIndex = -1;
+ const i = document.createElement('i');
+ i.className = `ph ${icon}`;
+ b.appendChild(i);
+ b.addEventListener('click', () => {
+ if (input.disabled || input.value === '') return;
+ if (dir > 0) input.stepUp();
+ else input.stepDown();
+ input.dispatchEvent(new Event('change'));
+ });
+ return b;
+ };
+ const col = document.createElement('div');
+ col.className = 'stepcol';
+ col.append(mk(1, 'ph-caret-up'), mk(-1, 'ph-caret-down'));
+ wrap.append(input, col);
+ }
+}
+
+function showMobileHistory() {
+ const undo = document.getElementById('undo');
+ const redo = document.getElementById('redo');
+ const host = document.getElementById('save')?.parentElement;
+ if (!undo || !redo || !host) return;
+ for (const b of [undo, redo]) {
+ b.removeAttribute('hidden');
+ b.classList.add('btn', 'icon');
+ }
+ host.append(undo, redo);
+}
+
+function initializePage() {
+ createIcons({ icons });
+ attachSteppers();
+
+ const fileInput = document.getElementById('file-input') as HTMLInputElement;
+ const dropZone = document.getElementById('drop-zone');
+
+ if (fileInput) {
+ const onPick = (e: Event) => {
+ const input = e.target as HTMLInputElement;
+ if (input.files && input.files.length > 0) {
+ handleFiles(input.files);
+ }
+ };
+ fileInput.addEventListener('change', onPick);
+ fileInput.addEventListener('input', onPick);
+ fileInput.addEventListener('click', () => {
+ fileInput.value = '';
+ });
+ }
+
+ if (dropZone) {
+ dropZone.addEventListener('dragover', (e) => {
+ e.preventDefault();
+ dropZone.classList.add('border-indigo-500');
+ });
+ dropZone.addEventListener('dragleave', () => {
+ dropZone.classList.remove('border-indigo-500');
+ });
+ dropZone.addEventListener('drop', (e) => {
+ e.preventDefault();
+ dropZone.classList.remove('border-indigo-500');
+ const files = e.dataTransfer?.files;
+ if (files && files.length > 0) {
+ handleFiles(files);
+ }
+ });
+ }
+
+ for (const id of ['stage', 'inspector']) {
+ const el = document.getElementById(id);
+ if (!el) continue;
+ let timer = 0;
+ el.addEventListener(
+ 'scroll',
+ () => {
+ el.classList.add('is-scrolling');
+ window.clearTimeout(timer);
+ timer = window.setTimeout(
+ () => el.classList.remove('is-scrolling'),
+ 700
+ );
+ },
+ { passive: true }
+ );
+ }
+
+ const inspector = document.getElementById('inspector');
+ const toggleInspector = document.getElementById('toggleInspector');
+ if (inspector && toggleInspector) {
+ toggleInspector.classList.add('on');
+ toggleInspector.addEventListener('click', () => {
+ inspector.classList.toggle('collapsed');
+ toggleInspector.classList.toggle(
+ 'on',
+ !inspector.classList.contains('collapsed')
+ );
+ });
+ if (window.matchMedia('(max-width: 768px)').matches) {
+ toggleInspector.classList.remove('on');
+ setupFormatDock();
+ setupFindSheet();
+ showMobileHistory();
+ }
+ }
+
+ const findBtn = document.getElementById('find');
+ const findbar = document.getElementById('findbar');
+ if (findBtn && findbar) {
+ const sync = () => findBtn.classList.toggle('on', !findbar.hidden);
+ new MutationObserver(sync).observe(findbar, {
+ attributes: true,
+ attributeFilter: ['hidden'],
+ });
+ sync();
+ }
+
+ const hud = document.getElementById('zoomHud');
+ if (hud) {
+ hud.removeAttribute('hidden');
+ let hudTimer = 0;
+ const flash = (text: string) => {
+ if (!document.getElementById('viewbar')?.hidden) return;
+ if (!text || text.includes('–')) return;
+ hud.textContent = text;
+ hud.classList.add('show');
+ window.clearTimeout(hudTimer);
+ hudTimer = window.setTimeout(() => hud.classList.remove('show'), 900);
+ };
+ for (const id of ['zoomLabel', 'pageLabel']) {
+ const label = document.getElementById(id);
+ if (!label) continue;
+ let last = label.textContent ?? '';
+ new MutationObserver(() => {
+ const now = label.textContent ?? '';
+ if (now === last) return;
+ last = now;
+ if (document.activeElement === label) return;
+ flash(now);
+ }).observe(label, {
+ childList: true,
+ characterData: true,
+ subtree: true,
+ });
+ }
+ }
+
+ const pageLabel = document.getElementById('pageLabel');
+ const pageInput = document.getElementById(
+ 'pageInput'
+ ) as HTMLInputElement | null;
+ const pageTotal = document.getElementById('pageTotal');
+ if (pageLabel && pageInput && pageTotal) {
+ const parse = () => {
+ const parts = (pageLabel.textContent ?? '').split('/');
+ return {
+ current: parseInt(parts[0] ?? '', 10),
+ total: parseInt(parts[1] ?? '', 10),
+ };
+ };
+ const sync = () => {
+ const { current, total } = parse();
+ if (!Number.isFinite(current) || !Number.isFinite(total)) return;
+ if (document.activeElement !== pageInput)
+ pageInput.value = String(current);
+ pageInput.max = String(total);
+ pageTotal.textContent = `/ ${total}`;
+ };
+ new MutationObserver(sync).observe(pageLabel, {
+ childList: true,
+ characterData: true,
+ subtree: true,
+ });
+ sync();
+ pageInput.addEventListener('keydown', (e) => {
+ if (e.key === 'Escape') {
+ sync();
+ pageInput.blur();
+ return;
+ }
+ if (e.key !== 'Enter') return;
+ e.preventDefault();
+ const { current, total } = parse();
+ const target = parseInt(pageInput.value, 10);
+ pageInput.blur();
+ if (
+ !Number.isFinite(current) ||
+ !Number.isFinite(total) ||
+ !Number.isFinite(target)
+ ) {
+ sync();
+ return;
+ }
+ const clamped = Math.min(Math.max(target, 1), total);
+ const delta = clamped - current;
+ if (delta === 0) {
+ sync();
+ return;
+ }
+ const btn = document.getElementById(delta > 0 ? 'next' : 'prev');
+ for (let i = 0; i < Math.abs(delta); i++) btn?.click();
+ });
+ pageInput.addEventListener('blur', sync);
+ }
+
+ const findText = document.getElementById(
+ 'findText'
+ ) as HTMLInputElement | null;
+ if (findText) {
+ let searchTimer = 0;
+ findText.addEventListener('input', () => {
+ window.clearTimeout(searchTimer);
+ if (!findText.value.trim()) return;
+ searchTimer = window.setTimeout(() => {
+ document.getElementById('findNext')?.click();
+ }, 350);
+ });
+ }
+
+ const stage = document.getElementById('stage');
+ const pageWrap = document.getElementById('pageWrap');
+ if (stage && pageWrap) {
+ let commitTimer = 0;
+ let gestureScale = 1;
+ let active = false;
+ let focusX = 0;
+ let focusY = 0;
+ const beginGesture = (clientX: number, clientY: number) => {
+ active = true;
+ gestureScale = 1;
+ const wrapRect = pageWrap.getBoundingClientRect();
+ focusX = clientX - wrapRect.left;
+ focusY = clientY - wrapRect.top;
+ pageWrap.style.transformOrigin = `${focusX}px ${focusY}px`;
+ pageWrap.style.willChange = 'transform';
+ };
+ const commitGesture = () => {
+ active = false;
+ const mod = appModule;
+ if (!mod) return;
+ const startZoom = mod.getZoom();
+ const target = Math.min(6, Math.max(0.2, startZoom * gestureScale));
+ const applied = target / startZoom;
+ const preLeft = stage.scrollLeft;
+ const preTop = stage.scrollTop;
+ pageWrap.style.transform = '';
+ pageWrap.style.willChange = '';
+ mod.setZoom(target);
+ stage.scrollLeft = preLeft + focusX * (applied - 1);
+ stage.scrollTop = preTop + focusY * (applied - 1);
+ gestureScale = 1;
+ };
+ stage.addEventListener(
+ 'wheel',
+ (e) => {
+ if (!e.ctrlKey && !e.metaKey) return;
+ e.preventDefault();
+ if (!appModule) return;
+ if (!active) beginGesture(e.clientX, e.clientY);
+ else window.clearTimeout(commitTimer);
+ gestureScale *= 1 - e.deltaY * 0.01;
+ gestureScale = Math.max(0.1, Math.min(10, gestureScale));
+ pageWrap.style.transform = `scale(${gestureScale})`;
+ commitTimer = window.setTimeout(commitGesture, 150);
+ },
+ { passive: false }
+ );
+ let pinchD0 = 0;
+ let pinchBase = 1;
+ stage.addEventListener(
+ 'touchstart',
+ (e) => {
+ if (e.touches.length !== 2 || !appModule) return;
+ e.preventDefault();
+ const [a, b] = [e.touches[0], e.touches[1]];
+ pinchD0 = Math.hypot(a.clientX - b.clientX, a.clientY - b.clientY);
+ if (!(pinchD0 > 0)) return;
+ window.clearTimeout(commitTimer);
+ if (!active) {
+ beginGesture(
+ (a.clientX + b.clientX) / 2,
+ (a.clientY + b.clientY) / 2
+ );
+ }
+ pinchBase = gestureScale;
+ },
+ { passive: false }
+ );
+ stage.addEventListener(
+ 'touchmove',
+ (e) => {
+ if (!active || e.touches.length !== 2 || !(pinchD0 > 0)) return;
+ e.preventDefault();
+ const [a, b] = [e.touches[0], e.touches[1]];
+ const d = Math.hypot(a.clientX - b.clientX, a.clientY - b.clientY);
+ gestureScale = Math.max(0.1, Math.min(10, pinchBase * (d / pinchD0)));
+ pageWrap.style.transform = `scale(${gestureScale})`;
+ },
+ { passive: false }
+ );
+ const endPinch = (e: TouchEvent) => {
+ if (!active || !(pinchD0 > 0)) return;
+ if (e.touches.length >= 2) return;
+ pinchD0 = 0;
+ window.clearTimeout(commitTimer);
+ commitTimer = window.setTimeout(commitGesture, 60);
+ };
+ stage.addEventListener('touchend', endPinch);
+ stage.addEventListener('touchcancel', endPinch);
+ }
+
+ const mainEl = document.getElementById('main');
+ if (mainEl) {
+ const DRAG_SEL =
+ '.obj-handle,.rot-handle,.para-handle,.edit-move,.multi-box,.obj-box,.sel-para';
+ mainEl.addEventListener(
+ 'touchstart',
+ (e) => {
+ if (e.touches.length !== 1) return;
+ const t = e.target as HTMLElement | null;
+ const grip = t?.closest?.(DRAG_SEL) as HTMLElement | null;
+ if (!grip) return;
+ e.preventDefault();
+ const tt = e.touches[0];
+ grip.dispatchEvent(
+ new MouseEvent('mousedown', {
+ bubbles: true,
+ clientX: tt.clientX,
+ clientY: tt.clientY,
+ buttons: 1,
+ })
+ );
+ const move = (ev: TouchEvent) => {
+ ev.preventDefault();
+ const m = ev.touches[0];
+ if (!m) return;
+ window.dispatchEvent(
+ new MouseEvent('mousemove', {
+ clientX: m.clientX,
+ clientY: m.clientY,
+ buttons: 1,
+ })
+ );
+ };
+ const end = (ev: TouchEvent) => {
+ const m = ev.changedTouches[0];
+ window.dispatchEvent(
+ new MouseEvent('mouseup', {
+ clientX: m?.clientX ?? tt.clientX,
+ clientY: m?.clientY ?? tt.clientY,
+ })
+ );
+ grip.removeEventListener('touchmove', move);
+ grip.removeEventListener('touchend', end);
+ grip.removeEventListener('touchcancel', end);
+ };
+ grip.addEventListener('touchmove', move, { passive: false });
+ grip.addEventListener('touchend', end);
+ grip.addEventListener('touchcancel', end);
+ },
+ { passive: false }
+ );
+ }
+
+ if (window.visualViewport) {
+ let kbTimer = 0;
+ let lastVh = window.visualViewport.height;
+ const app = document.getElementById('text-editor-app');
+ window.visualViewport.addEventListener('resize', () => {
+ const vv = window.visualViewport;
+ if (!vv) return;
+ const shrunk = vv.height < lastVh - 80;
+ lastVh = vv.height;
+ app?.classList.toggle('kb-open', window.innerHeight - vv.height > 150);
+ if (!shrunk) return;
+ window.clearTimeout(kbTimer);
+ kbTimer = window.setTimeout(() => {
+ const ed = document.querySelector('#overlay .editor');
+ ed?.scrollIntoView({ block: 'center', behavior: 'smooth' });
+ }, 120);
+ });
+ }
+
+ document.getElementById('back-to-tools')?.addEventListener('click', () => {
+ window.location.href = import.meta.env.BASE_URL;
+ });
+
+ document.getElementById('exitEditor')?.addEventListener('click', () => {
+ window.location.reload();
+ });
+}
+
+if (document.readyState === 'loading') {
+ document.addEventListener('DOMContentLoaded', initializePage);
+} else {
+ initializePage();
+}
diff --git a/src/js/logic/form-creator.ts b/src/js/logic/form-creator.ts
index 387c407d..76c62cb7 100644
--- a/src/js/logic/form-creator.ts
+++ b/src/js/logic/form-creator.ts
@@ -30,10 +30,6 @@ import * as bwipjs from 'bwip-js/browser';
import 'pdfjs-dist/web/pdf_viewer.css';
// Initialize PDF.js worker
-pdfjsLib.GlobalWorkerOptions.workerSrc = new URL(
- 'pdfjs-dist/build/pdf.worker.min.mjs',
- import.meta.url
-).toString();
import {
ExtractExistingFieldsResult,
@@ -43,6 +39,7 @@ import {
} from '@/types';
import { extractExistingFields as extractExistingPdfFields } from './form-creator-extraction.js';
import { loadPdfDocument } from '../utils/load-pdf-document.js';
+import '../utils/setup-pdf-worker.js';
let fields: FormField[] = [];
let selectedField: FormField | null = null;
diff --git a/src/js/logic/invert-colors-page.ts b/src/js/logic/invert-colors-page.ts
index 4eceb6fe..92a9a166 100644
--- a/src/js/logic/invert-colors-page.ts
+++ b/src/js/logic/invert-colors-page.ts
@@ -7,11 +7,7 @@ import * as pdfjsLib from 'pdfjs-dist';
import { InvertColorsState } from '@/types';
import { loadPdfWithPasswordPrompt } from '../utils/password-prompt.js';
import { loadPdfDocument } from '../utils/load-pdf-document.js';
-
-pdfjsLib.GlobalWorkerOptions.workerSrc = new URL(
- 'pdfjs-dist/build/pdf.worker.min.mjs',
- import.meta.url
-).toString();
+import '../utils/setup-pdf-worker.js';
const pageState: InvertColorsState = { file: null, pdfDoc: null };
diff --git a/src/js/logic/merge-pdf-page.ts b/src/js/logic/merge-pdf-page.ts
index 3ac4ca1f..18a4303d 100644
--- a/src/js/logic/merge-pdf-page.ts
+++ b/src/js/logic/merge-pdf-page.ts
@@ -17,11 +17,7 @@ import { createIcons, icons } from 'lucide';
import * as pdfjsLib from 'pdfjs-dist';
import Sortable from 'sortablejs';
import type { MergeJob, MergeFile, MergeMessage, MergeResponse } from '@/types';
-
-pdfjsLib.GlobalWorkerOptions.workerSrc = new URL(
- 'pdfjs-dist/build/pdf.worker.min.mjs',
- import.meta.url
-).toString();
+import '../utils/setup-pdf-worker.js';
interface MergeState {
pdfDocs: Record;
@@ -389,12 +385,17 @@ export async function merge() {
'retain-page-labels'
) as HTMLInputElement | null;
+ const removeDuplicateFontsCheckbox = document.getElementById(
+ 'remove-duplicate-fonts'
+ ) as HTMLInputElement | null;
+
const message: MergeMessage = {
command: 'merge',
files: filesToMerge,
jobs: jobs,
cpdfUrl: WasmProvider.getUrl('cpdf')! + 'coherentpdf.browser.min.js',
retainPageLabels: retainCheckbox?.checked ?? false,
+ removeDuplicateFonts: removeDuplicateFontsCheckbox?.checked ?? false,
};
mergeWorker.postMessage(
diff --git a/src/js/logic/organize-pdf-page.ts b/src/js/logic/organize-pdf-page.ts
index 9a026c33..e1ebd1c8 100644
--- a/src/js/logic/organize-pdf-page.ts
+++ b/src/js/logic/organize-pdf-page.ts
@@ -7,11 +7,7 @@ import { loadPdfWithPasswordPrompt } from '../utils/password-prompt.js';
import * as pdfjsLib from 'pdfjs-dist';
import Sortable from 'sortablejs';
import { loadPdfDocument } from '../utils/load-pdf-document.js';
-
-pdfjsLib.GlobalWorkerOptions.workerSrc = new URL(
- 'pdfjs-dist/build/pdf.worker.min.mjs',
- import.meta.url
-).toString();
+import '../utils/setup-pdf-worker.js';
interface OrganizeState {
file: File | null;
diff --git a/src/js/logic/pdf-booklet-page.ts b/src/js/logic/pdf-booklet-page.ts
index 30253ffb..bb0f1ba2 100644
--- a/src/js/logic/pdf-booklet-page.ts
+++ b/src/js/logic/pdf-booklet-page.ts
@@ -5,11 +5,7 @@ import { PDFDocument as PDFLibDocument, degrees, PageSizes } from 'pdf-lib';
import * as pdfjsLib from 'pdfjs-dist';
import { loadPdfWithPasswordPrompt } from '../utils/password-prompt.js';
import { loadPdfDocument } from '../utils/load-pdf-document.js';
-
-pdfjsLib.GlobalWorkerOptions.workerSrc = new URL(
- 'pdfjs-dist/build/pdf.worker.mjs',
- import.meta.url
-).toString();
+import '../utils/setup-pdf-worker.js';
interface BookletState {
file: File | null;
diff --git a/src/js/logic/pdf-multi-tool.ts b/src/js/logic/pdf-multi-tool.ts
index fd7e140f..6761c74d 100644
--- a/src/js/logic/pdf-multi-tool.ts
+++ b/src/js/logic/pdf-multi-tool.ts
@@ -15,13 +15,9 @@ import { repairPdfFile } from './repair-pdf.js';
import { partitionIncomingFiles } from '../utils/multi-tool-file-input.js';
import { convertImagesToPdfFile } from '../utils/images-to-pdf-lib.js';
-pdfjsLib.GlobalWorkerOptions.workerSrc = new URL(
- 'pdfjs-dist/build/pdf.worker.min.mjs',
- import.meta.url
-).toString();
-
import { t } from '../i18n/i18n';
import { loadPdfDocument } from '../utils/load-pdf-document.js';
+import '../utils/setup-pdf-worker.js';
interface PageData {
id: string; // Unique ID for DOM reconciliation
diff --git a/src/js/logic/pdf-to-bmp-page.ts b/src/js/logic/pdf-to-bmp-page.ts
index 2e8356cc..bb53af40 100644
--- a/src/js/logic/pdf-to-bmp-page.ts
+++ b/src/js/logic/pdf-to-bmp-page.ts
@@ -12,11 +12,7 @@ import * as pdfjsLib from 'pdfjs-dist';
import { PDFPageProxy } from 'pdfjs-dist';
import { t } from '../i18n/i18n';
import { loadPdfWithPasswordPrompt } from '../utils/password-prompt.js';
-
-pdfjsLib.GlobalWorkerOptions.workerSrc = new URL(
- 'pdfjs-dist/build/pdf.worker.min.mjs',
- import.meta.url
-).toString();
+import '../utils/setup-pdf-worker.js';
let files: File[] = [];
diff --git a/src/js/logic/pdf-to-cbz-page.ts b/src/js/logic/pdf-to-cbz-page.ts
index 0e1fc881..599ff05a 100644
--- a/src/js/logic/pdf-to-cbz-page.ts
+++ b/src/js/logic/pdf-to-cbz-page.ts
@@ -18,11 +18,7 @@ import {
} from '../utils/comic-info.js';
import type { CbzOptions, ComicMetadata } from '@/types';
import { loadPdfWithPasswordPrompt } from '../utils/password-prompt.js';
-
-pdfjsLib.GlobalWorkerOptions.workerSrc = new URL(
- 'pdfjs-dist/build/pdf.worker.min.mjs',
- import.meta.url
-).toString();
+import '../utils/setup-pdf-worker.js';
let files: File[] = [];
diff --git a/src/js/logic/pdf-to-greyscale-page.ts b/src/js/logic/pdf-to-greyscale-page.ts
index d170d095..fc0bd933 100644
--- a/src/js/logic/pdf-to-greyscale-page.ts
+++ b/src/js/logic/pdf-to-greyscale-page.ts
@@ -11,11 +11,7 @@ import { applyGreyscale } from '../utils/image-effects.js';
import * as pdfjsLib from 'pdfjs-dist';
import { t } from '../i18n/i18n';
import { loadPdfWithPasswordPrompt } from '../utils/password-prompt.js';
-
-pdfjsLib.GlobalWorkerOptions.workerSrc = new URL(
- 'pdfjs-dist/build/pdf.worker.min.mjs',
- import.meta.url
-).toString();
+import '../utils/setup-pdf-worker.js';
let files: File[] = [];
diff --git a/src/js/logic/pdf-to-jpg-page.ts b/src/js/logic/pdf-to-jpg-page.ts
index 03a3cc6e..33a7dfd8 100644
--- a/src/js/logic/pdf-to-jpg-page.ts
+++ b/src/js/logic/pdf-to-jpg-page.ts
@@ -12,11 +12,7 @@ import * as pdfjsLib from 'pdfjs-dist';
import { PDFPageProxy } from 'pdfjs-dist';
import { t } from '../i18n/i18n';
import { loadPdfWithPasswordPrompt } from '../utils/password-prompt.js';
-
-pdfjsLib.GlobalWorkerOptions.workerSrc = new URL(
- 'pdfjs-dist/build/pdf.worker.min.mjs',
- import.meta.url
-).toString();
+import '../utils/setup-pdf-worker.js';
let files: File[] = [];
diff --git a/src/js/logic/pdf-to-png-page.ts b/src/js/logic/pdf-to-png-page.ts
index 7da42589..8401ea48 100644
--- a/src/js/logic/pdf-to-png-page.ts
+++ b/src/js/logic/pdf-to-png-page.ts
@@ -12,11 +12,7 @@ import * as pdfjsLib from 'pdfjs-dist';
import { PDFPageProxy } from 'pdfjs-dist';
import { t } from '../i18n/i18n';
import { loadPdfWithPasswordPrompt } from '../utils/password-prompt.js';
-
-pdfjsLib.GlobalWorkerOptions.workerSrc = new URL(
- 'pdfjs-dist/build/pdf.worker.min.mjs',
- import.meta.url
-).toString();
+import '../utils/setup-pdf-worker.js';
let files: File[] = [];
diff --git a/src/js/logic/pdf-to-tiff-page.ts b/src/js/logic/pdf-to-tiff-page.ts
index 7d713dba..a4b444f3 100644
--- a/src/js/logic/pdf-to-tiff-page.ts
+++ b/src/js/logic/pdf-to-tiff-page.ts
@@ -15,11 +15,7 @@ import type Vips from 'wasm-vips';
import wasmUrl from 'wasm-vips/vips.wasm?url';
import type { TiffOptions } from '@/types';
import { loadPdfWithPasswordPrompt } from '../utils/password-prompt.js';
-
-pdfjsLib.GlobalWorkerOptions.workerSrc = new URL(
- 'pdfjs-dist/build/pdf.worker.min.mjs',
- import.meta.url
-).toString();
+import '../utils/setup-pdf-worker.js';
let files: File[] = [];
let vipsInstance: typeof Vips | null = null;
@@ -36,6 +32,7 @@ async function getVips(): Promise {
return fileName;
},
});
+ vipsInstance.Cache.max(0);
return vipsInstance;
}
@@ -145,7 +142,11 @@ async function renderPageToRgba(
}).promise;
const imageData = context.getImageData(0, 0, canvas.width, canvas.height);
- return { rgba: imageData.data, width: canvas.width, height: canvas.height };
+ const { width, height } = canvas;
+ page.cleanup();
+ canvas.width = 0;
+ canvas.height = 0;
+ return { rgba: imageData.data, width, height };
}
function encodePageToTiff(
@@ -155,52 +156,60 @@ function encodePageToTiff(
height: number,
options: TiffOptions
): Uint8Array {
- let image = vips.Image.newFromMemory(
- new Uint8Array(rgba.buffer, rgba.byteOffset, rgba.byteLength),
- width,
- height,
- 4,
- vips.BandFormat.uchar
- );
-
- image = image.copy();
- const pixelsPerMm = options.dpi / 25.4;
- image.setDouble('xres', pixelsPerMm);
- image.setDouble('yres', pixelsPerMm);
-
- if (options.colorMode === 'greyscale' || options.colorMode === 'bw') {
- if (image.bands === 4) {
- image = image.flatten({ background: [255, 255, 255] });
- }
- image = image.colourspace(vips.Interpretation.b_w);
- } else {
- if (image.bands === 4) {
- image = image.flatten({ background: [255, 255, 255] });
- }
- }
-
- const tiffOptions: Parameters[0] = {
- compression: options.compression as Vips.Enum,
- resunit: vips.ForeignTiffResunit.inch,
- xres: options.dpi / 25.4,
- yres: options.dpi / 25.4,
- predictor:
- options.compression === 'lzw' || options.compression === 'deflate'
- ? vips.ForeignTiffPredictor.horizontal
- : vips.ForeignTiffPredictor.none,
+ const intermediates: Vips.Image[] = [];
+ const track = (img: Vips.Image): Vips.Image => {
+ intermediates.push(img);
+ return img;
};
- if (options.colorMode === 'bw') {
- tiffOptions.bitdepth = 1;
- }
+ try {
+ let image = track(
+ vips.Image.newFromMemory(
+ new Uint8Array(rgba.buffer, rgba.byteOffset, rgba.byteLength),
+ width,
+ height,
+ 4,
+ vips.BandFormat.uchar
+ )
+ );
- if (options.compression === 'jpeg') {
- tiffOptions.Q = 85;
- }
+ image = track(image.copy());
+ const pixelsPerMm = options.dpi / 25.4;
+ image.setDouble('xres', pixelsPerMm);
+ image.setDouble('yres', pixelsPerMm);
- const buffer = image.tiffsaveBuffer(tiffOptions);
- image.delete();
- return buffer;
+ if (image.bands === 4) {
+ image = track(image.flatten({ background: [255, 255, 255] }));
+ }
+ if (options.colorMode === 'greyscale' || options.colorMode === 'bw') {
+ image = track(image.colourspace(vips.Interpretation.b_w));
+ }
+
+ const tiffOptions: Parameters[0] = {
+ compression: options.compression as Vips.Enum,
+ resunit: vips.ForeignTiffResunit.inch,
+ xres: options.dpi / 25.4,
+ yres: options.dpi / 25.4,
+ predictor:
+ options.compression === 'lzw' || options.compression === 'deflate'
+ ? vips.ForeignTiffPredictor.horizontal
+ : vips.ForeignTiffPredictor.none,
+ };
+
+ if (options.colorMode === 'bw') {
+ tiffOptions.bitdepth = 1;
+ }
+
+ if (options.compression === 'jpeg') {
+ tiffOptions.Q = 85;
+ }
+
+ return image.tiffsaveBuffer(tiffOptions);
+ } finally {
+ for (const img of intermediates) {
+ if (!img.isDeleted()) img.delete();
+ }
+ }
}
async function convert() {
@@ -239,68 +248,88 @@ async function convert() {
if (options.multiPage && pdf.numPages > 1) {
const pages: Vips.Image[] = [];
- for (let i = 1; i <= pdf.numPages; i++) {
- const page = await pdf.getPage(i);
- const { rgba, width, height } = await renderPageToRgba(
- page,
- options.dpi
- );
+ try {
+ for (let i = 1; i <= pdf.numPages; i++) {
+ const page = await pdf.getPage(i);
+ const { rgba, width, height } = await renderPageToRgba(
+ page,
+ options.dpi
+ );
- let img = vips.Image.newFromMemory(
- new Uint8Array(rgba.buffer, rgba.byteOffset, rgba.byteLength),
- width,
- height,
- 4,
- vips.BandFormat.uchar
- );
+ const intermediates: Vips.Image[] = [];
+ const track = (image: Vips.Image): Vips.Image => {
+ intermediates.push(image);
+ return image;
+ };
- if (options.colorMode === 'greyscale' || options.colorMode === 'bw') {
- if (img.bands === 4) {
- img = img.flatten({ background: [255, 255, 255] });
- }
- img = img.colourspace(vips.Interpretation.b_w);
- } else {
- if (img.bands === 4) {
- img = img.flatten({ background: [255, 255, 255] });
+ try {
+ let img = track(
+ vips.Image.newFromMemory(
+ new Uint8Array(rgba.buffer, rgba.byteOffset, rgba.byteLength),
+ width,
+ height,
+ 4,
+ vips.BandFormat.uchar
+ )
+ );
+
+ if (img.bands === 4) {
+ img = track(img.flatten({ background: [255, 255, 255] }));
+ }
+ if (
+ options.colorMode === 'greyscale' ||
+ options.colorMode === 'bw'
+ ) {
+ img = track(img.colourspace(vips.Interpretation.b_w));
+ }
+
+ pages.push(img.copyMemory());
+ } finally {
+ for (const img of intermediates) {
+ if (!img.isDeleted()) img.delete();
+ }
}
}
- pages.push(img);
- }
+ const firstPage = pages[0];
+ let joined = firstPage;
+ if (pages.length > 1) {
+ joined = vips.Image.arrayjoin(pages, { across: 1 });
+ }
- const firstPage = pages[0];
- let joined = firstPage;
- if (pages.length > 1) {
- joined = vips.Image.arrayjoin(pages, { across: 1 });
- }
+ try {
+ const tiffOptions: Parameters[0] = {
+ compression: options.compression as Vips.Enum,
+ resunit: vips.ForeignTiffResunit.inch,
+ xres: options.dpi / 25.4,
+ yres: options.dpi / 25.4,
+ page_height: firstPage.height,
+ predictor:
+ options.compression === 'lzw' || options.compression === 'deflate'
+ ? vips.ForeignTiffPredictor.horizontal
+ : vips.ForeignTiffPredictor.none,
+ };
- const tiffOptions: Parameters[0] = {
- compression: options.compression as Vips.Enum,
- resunit: vips.ForeignTiffResunit.inch,
- xres: options.dpi / 25.4,
- yres: options.dpi / 25.4,
- page_height: firstPage.height,
- predictor:
- options.compression === 'lzw' || options.compression === 'deflate'
- ? vips.ForeignTiffPredictor.horizontal
- : vips.ForeignTiffPredictor.none,
- };
+ if (options.colorMode === 'bw') {
+ tiffOptions.bitdepth = 1;
+ }
- if (options.colorMode === 'bw') {
- tiffOptions.bitdepth = 1;
- }
+ if (options.compression === 'jpeg') {
+ tiffOptions.Q = 85;
+ }
- if (options.compression === 'jpeg') {
- tiffOptions.Q = 85;
- }
-
- const buffer = joined.tiffsaveBuffer(tiffOptions);
- const blob = new Blob([new Uint8Array(buffer)], { type: 'image/tiff' });
- downloadFile(blob, getCleanPdfFilename(files[0].name) + '.tiff');
-
- joined.delete();
- for (const p of pages) {
- if (!p.isDeleted()) p.delete();
+ const buffer = joined.tiffsaveBuffer(tiffOptions);
+ const blob = new Blob([new Uint8Array(buffer)], {
+ type: 'image/tiff',
+ });
+ downloadFile(blob, getCleanPdfFilename(files[0].name) + '.tiff');
+ } finally {
+ if (joined !== firstPage && !joined.isDeleted()) joined.delete();
+ }
+ } finally {
+ for (const p of pages) {
+ if (!p.isDeleted()) p.delete();
+ }
}
} else if (pdf.numPages === 1) {
const page = await pdf.getPage(1);
@@ -334,7 +363,15 @@ async function convert() {
);
} catch (e) {
console.error(e);
- showAlert(t('common.error'), t('tools:pdfToTiff.alert.conversionError'));
+ const message = e instanceof Error ? e.message : String(e);
+ if (/memory/i.test(message)) {
+ showAlert(
+ t('common.error'),
+ t('tools:pdfToTiff.alert.conversionOutOfMemory')
+ );
+ } else {
+ showAlert(t('common.error'), t('tools:pdfToTiff.alert.conversionError'));
+ }
} finally {
hideLoader();
}
diff --git a/src/js/logic/pdf-to-webp-page.ts b/src/js/logic/pdf-to-webp-page.ts
index 85bb82c6..93b08e97 100644
--- a/src/js/logic/pdf-to-webp-page.ts
+++ b/src/js/logic/pdf-to-webp-page.ts
@@ -12,11 +12,7 @@ import * as pdfjsLib from 'pdfjs-dist';
import { PDFPageProxy } from 'pdfjs-dist';
import { t } from '../i18n/i18n';
import { loadPdfWithPasswordPrompt } from '../utils/password-prompt.js';
-
-pdfjsLib.GlobalWorkerOptions.workerSrc = new URL(
- 'pdfjs-dist/build/pdf.worker.min.mjs',
- import.meta.url
-).toString();
+import '../utils/setup-pdf-worker.js';
let files: File[] = [];
diff --git a/src/js/logic/posterize-page.ts b/src/js/logic/posterize-page.ts
index 1bc1f80c..3951203d 100644
--- a/src/js/logic/posterize-page.ts
+++ b/src/js/logic/posterize-page.ts
@@ -9,11 +9,7 @@ import { PDFDocument, PageSizes } from 'pdf-lib';
import * as pdfjsLib from 'pdfjs-dist';
import { createIcons, icons } from 'lucide';
import { PosterizeState } from '@/types';
-
-pdfjsLib.GlobalWorkerOptions.workerSrc = new URL(
- 'pdfjs-dist/build/pdf.worker.min.mjs',
- import.meta.url
-).toString();
+import '../utils/setup-pdf-worker.js';
const pageState: PosterizeState = {
file: null,
diff --git a/src/js/logic/remove-blank-pages-page.ts b/src/js/logic/remove-blank-pages-page.ts
index 6ca7d38d..5b70b371 100644
--- a/src/js/logic/remove-blank-pages-page.ts
+++ b/src/js/logic/remove-blank-pages-page.ts
@@ -5,11 +5,7 @@ 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',
- import.meta.url
-).toString();
+import '../utils/setup-pdf-worker.js';
// State
const pageState: {
diff --git a/src/js/logic/rotate-custom-page.ts b/src/js/logic/rotate-custom-page.ts
index 0f7534ed..f6434758 100644
--- a/src/js/logic/rotate-custom-page.ts
+++ b/src/js/logic/rotate-custom-page.ts
@@ -9,6 +9,7 @@ import {
import { loadPdfWithPasswordPrompt } from '../utils/password-prompt.js';
import * as pdfjsLib from 'pdfjs-dist';
import { loadPdfDocument } from '../utils/load-pdf-document.js';
+import '../utils/setup-pdf-worker.js';
import {
ROTATION_MIN,
ROTATION_MAX,
@@ -18,11 +19,6 @@ import {
parseAngleInput,
} from '../utils/rotation-utils.js';
-pdfjsLib.GlobalWorkerOptions.workerSrc = new URL(
- 'pdfjs-dist/build/pdf.worker.min.mjs',
- import.meta.url
-).toString();
-
interface RotateState {
file: File | null;
pdfDoc: PDFLibDocument | null;
diff --git a/src/js/logic/rotate-pdf-page.ts b/src/js/logic/rotate-pdf-page.ts
index 079102b0..52cf4b97 100644
--- a/src/js/logic/rotate-pdf-page.ts
+++ b/src/js/logic/rotate-pdf-page.ts
@@ -10,11 +10,7 @@ import { rotatePdfPages } from '../utils/pdf-operations.js';
import { loadPdfWithPasswordPrompt } from '../utils/password-prompt.js';
import * as pdfjsLib from 'pdfjs-dist';
import { loadPdfDocument } from '../utils/load-pdf-document.js';
-
-pdfjsLib.GlobalWorkerOptions.workerSrc = new URL(
- 'pdfjs-dist/build/pdf.worker.min.mjs',
- import.meta.url
-).toString();
+import '../utils/setup-pdf-worker.js';
interface RotateState {
file: File | null;
diff --git a/src/js/logic/scan-to-pdf.ts b/src/js/logic/scan-to-pdf.ts
index fc83f743..73e82450 100644
--- a/src/js/logic/scan-to-pdf.ts
+++ b/src/js/logic/scan-to-pdf.ts
@@ -1,5 +1,5 @@
// This is essentially the same as image-to-pdf.
-// Note: This is a legacy stub - scan-to-pdf functionality is now handled by image-to-pdf page
+// Note: This is a legacy stub - scan-to-pdf functionality is now handled by the image-to-pdf page
export const scanToPdf = () => {
console.warn('scan-to-pdf is deprecated, redirecting to image-to-pdf page');
};
diff --git a/src/js/logic/scanner-effect-page.ts b/src/js/logic/scanner-effect-page.ts
index 48d5e538..22b06427 100644
--- a/src/js/logic/scanner-effect-page.ts
+++ b/src/js/logic/scanner-effect-page.ts
@@ -12,11 +12,7 @@ import * as pdfjsLib from 'pdfjs-dist';
import type { ScanSettings } from '../types/scanner-effect-type.js';
import { t } from '../i18n/i18n';
import { loadPdfWithPasswordPrompt } from '../utils/password-prompt.js';
-
-pdfjsLib.GlobalWorkerOptions.workerSrc = new URL(
- 'pdfjs-dist/build/pdf.worker.min.mjs',
- import.meta.url
-).toString();
+import '../utils/setup-pdf-worker.js';
let files: File[] = [];
let cachedBaselineData: ImageData | null = null;
diff --git a/src/js/logic/split-pdf-page.ts b/src/js/logic/split-pdf-page.ts
index 1cfff1d1..6e9d7746 100644
--- a/src/js/logic/split-pdf-page.ts
+++ b/src/js/logic/split-pdf-page.ts
@@ -20,6 +20,7 @@ import { showWasmRequiredDialog } from '../utils/wasm-provider.js';
import JSZip from 'jszip';
import { loadPdfDocument } from '../utils/load-pdf-document.js';
import type { QpdfInstanceExtended } from '@/types';
+import '../utils/setup-pdf-worker.js';
import {
parseRangeGroups,
evenOddIndices,
@@ -31,11 +32,6 @@ import {
extractPagesWithQpdf,
} from '../utils/split-pdf-helpers.js';
-pdfjsLib.GlobalWorkerOptions.workerSrc = new URL(
- 'pdfjs-dist/build/pdf.worker.min.mjs',
- import.meta.url
-).toString();
-
document.addEventListener('DOMContentLoaded', () => {
let visualSelectorRendered = false;
let isSplitting = false;
diff --git a/src/js/logic/text-color-page.ts b/src/js/logic/text-color-page.ts
index d184c2a2..5d6dfe35 100644
--- a/src/js/logic/text-color-page.ts
+++ b/src/js/logic/text-color-page.ts
@@ -12,11 +12,7 @@ import * as pdfjsLib from 'pdfjs-dist';
import { TextColorState } from '@/types';
import { loadPdfWithPasswordPrompt } from '../utils/password-prompt.js';
import { loadPdfDocument } from '../utils/load-pdf-document.js';
-
-pdfjsLib.GlobalWorkerOptions.workerSrc = new URL(
- 'pdfjs-dist/build/pdf.worker.min.mjs',
- import.meta.url
-).toString();
+import '../utils/setup-pdf-worker.js';
const pageState: TextColorState = { file: null, pdfDoc: null };
diff --git a/src/js/logic/word-to-pdf.ts b/src/js/logic/word-to-pdf.ts
index 2edb9238..a7a7f8a9 100644
--- a/src/js/logic/word-to-pdf.ts
+++ b/src/js/logic/word-to-pdf.ts
@@ -1,4 +1,4 @@
-// NOTE: This is a work in progress and does not work correctly as of yet
+// NOTE: This is a work in progress and does not work correctly yet
import DOMPurify from 'dompurify';
import { showLoader, hideLoader, showAlert } from '../ui.js';
import { readFileAsArrayBuffer } from '../utils/helpers.js';
diff --git a/src/js/main.ts b/src/js/main.ts
index 78d4c41d..21565646 100644
--- a/src/js/main.ts
+++ b/src/js/main.ts
@@ -1,3 +1,5 @@
+import './utils/map-upsert-polyfill.js';
+import './utils/setup-pdf-worker.js';
import { categories } from './config/tools.js';
import { dom, switchView, hideAlert } from './ui.js';
import { ShortcutsManager } from './logic/shortcuts.js';
@@ -22,6 +24,11 @@ import {
isToolDisabled,
isCurrentPageDisabled,
} from './utils/disabled-tools.js';
+import {
+ getStoredItem,
+ setStoredItem,
+ removeStoredItem,
+} from './utils/safe-storage.js';
declare const __BRAND_NAME__: string;
const init = async () => {
@@ -49,10 +56,6 @@ const init = async () => {
return;
}
- pdfjsLib.GlobalWorkerOptions.workerSrc = new URL(
- 'pdfjs-dist/build/pdf.worker.min.mjs',
- import.meta.url
- ).toString();
if (__SIMPLE_MODE__) {
const hideBrandingSections = () => {
const heroSection = document.getElementById('hero-section');
@@ -171,6 +174,7 @@ const init = async () => {
'Split PDF': 'tools:splitPdf',
'Compress PDF': 'tools:compressPdf',
'PDF Editor': 'tools:pdfEditor',
+ 'Edit PDF Text': 'tools:editPdfText',
'JPG to PDF': 'tools:jpgToPdf',
'Sign PDF': 'tools:signPdf',
'Crop PDF': 'tools:cropPdf',
@@ -211,6 +215,7 @@ const init = async () => {
'PDF to JSON': 'tools:pdfToJson',
'OCR PDF': 'tools:ocrPdf',
'Alternate & Mix Pages': 'tools:alternateMerge',
+ 'Duplex Collate': 'tools:duplexCollate',
'PDF Overlay': 'tools:pdfOverlay',
'Organize & Duplicate': 'tools:duplicateOrganize',
'Add Attachments': 'tools:addAttachments',
@@ -290,17 +295,14 @@ const init = async () => {
let collapsedCategories: string[] = [];
try {
- const stored = localStorage.getItem('collapsedCategories');
+ const stored = getStoredItem('collapsedCategories');
if (stored) collapsedCategories = JSON.parse(stored);
} catch {
- localStorage.removeItem('collapsedCategories');
+ removeStoredItem('collapsedCategories');
}
function saveCollapsedCategories() {
- localStorage.setItem(
- 'collapsedCategories',
- JSON.stringify(collapsedCategories)
- );
+ setStoredItem('collapsedCategories', JSON.stringify(collapsedCategories));
}
const filteredCategories = categories
@@ -545,7 +547,11 @@ const init = async () => {
document.getElementById('github-stars-mobile'),
];
- if (githubStarsElements.some((el) => el) && !__SIMPLE_MODE__) {
+ if (
+ githubStarsElements.some((el) => el) &&
+ !__SIMPLE_MODE__ &&
+ !__DISABLE_GITHUB_STARS__
+ ) {
fetch('https://api.github.com/repos/alam00000/bentopdf')
.then((response) => response.json())
.then((data) => {
@@ -611,7 +617,7 @@ const init = async () => {
) as HTMLInputElement;
const toolInterface = document.getElementById('tool-interface');
- const savedFullWidth = localStorage.getItem('fullWidthMode') !== 'false';
+ const savedFullWidth = getStoredItem('fullWidthMode') !== 'false';
if (fullWidthToggle) {
fullWidthToggle.checked = savedFullWidth;
applyFullWidthMode(savedFullWidth);
@@ -648,7 +654,7 @@ const init = async () => {
if (fullWidthToggle) {
fullWidthToggle.addEventListener('change', (e) => {
const enabled = (e.target as HTMLInputElement).checked;
- localStorage.setItem('fullWidthMode', enabled.toString());
+ setStoredItem('fullWidthMode', enabled.toString());
applyFullWidthMode(enabled);
});
}
@@ -657,7 +663,7 @@ const init = async () => {
'compact-mode-toggle'
) as HTMLInputElement;
- const savedCompactMode = localStorage.getItem('compactMode') === 'true';
+ const savedCompactMode = getStoredItem('compactMode') === 'true';
if (compactModeToggle) {
compactModeToggle.checked = savedCompactMode;
}
@@ -677,7 +683,7 @@ const init = async () => {
if (compactModeToggle) {
compactModeToggle.addEventListener('change', (e) => {
const enabled = (e.target as HTMLInputElement).checked;
- localStorage.setItem('compactMode', enabled.toString());
+ setStoredItem('compactMode', enabled.toString());
applyCompactMode(enabled);
});
}
diff --git a/src/js/pdf.worker.ts b/src/js/pdf.worker.ts
new file mode 100644
index 00000000..e3dbeeac
--- /dev/null
+++ b/src/js/pdf.worker.ts
@@ -0,0 +1,2 @@
+import './utils/map-upsert-polyfill.js';
+import 'pdfjs-dist/build/pdf.worker.min.mjs';
diff --git a/src/js/types/add-watermark-type.ts b/src/js/types/add-watermark-type.ts
index 898d5c4e..f300d995 100644
--- a/src/js/types/add-watermark-type.ts
+++ b/src/js/types/add-watermark-type.ts
@@ -9,8 +9,13 @@ export interface AddWatermarkState {
watermarkY: number; // 0–1, percentage from top (flipped to bottom for PDF)
}
+export type WatermarkLayout = 'single' | 'tile';
+
export interface PageWatermarkConfig {
type: 'text' | 'image';
+ layout: WatermarkLayout;
+ tileGapX: number;
+ tileGapY: number;
x: number;
y: number;
text: string;
diff --git a/src/js/types/edit-pdf-type.ts b/src/js/types/edit-pdf-type.ts
index a6d80671..90fa11b2 100644
--- a/src/js/types/edit-pdf-type.ts
+++ b/src/js/types/edit-pdf-type.ts
@@ -13,3 +13,49 @@ export interface DocManagerPlugin {
closeDocument: (id: string) => void;
saveAsCopy: (id: string) => Promise;
}
+
+export interface EditorAnnotationRect {
+ origin: { x: number; y: number };
+ size: { width: number; height: number };
+}
+
+export interface EditorAnnotationObjectLite {
+ id?: string;
+ pageIndex?: number;
+ type?: number;
+ intent?: string;
+ contents?: string;
+ fontSize?: number;
+ fontColor?: string;
+ textAlign?: number;
+ verticalAlign?: number;
+ opacity?: number;
+ color?: string;
+ backgroundColor?: string;
+ rect?: EditorAnnotationRect;
+ rotation?: number;
+ fontFamilyName?: string;
+ fontPostScriptName?: string;
+ fontFaceName?: string;
+}
+
+export interface AnnotationPluginLite {
+ getState: () => {
+ byUid: Record;
+ };
+}
+
+export interface FreeTextSystemFontAnnotation {
+ id: string;
+ pageIndex: number;
+ contents: string;
+ fontSize: number;
+ fontColor: string;
+ textAlign: number;
+ verticalAlign: number;
+ opacity: number;
+ backgroundColor?: string;
+ rect: EditorAnnotationRect;
+ fontPostScriptName: string;
+ rotation?: number;
+}
diff --git a/src/js/types/merge-worker-type.ts b/src/js/types/merge-worker-type.ts
index 1e093602..d473a9ff 100644
--- a/src/js/types/merge-worker-type.ts
+++ b/src/js/types/merge-worker-type.ts
@@ -18,6 +18,7 @@ export interface MergeMessage {
jobs: MergeJob[];
cpdfUrl?: string;
retainPageLabels?: boolean;
+ removeDuplicateFonts?: boolean;
}
export interface MergeSuccessResponse {
diff --git a/src/js/ui.ts b/src/js/ui.ts
index 97b1a95a..6f93122f 100644
--- a/src/js/ui.ts
+++ b/src/js/ui.ts
@@ -14,11 +14,7 @@ import {
import * as pdfjsLib from 'pdfjs-dist';
import { t } from './i18n/i18n';
import type { FileInputOptions } from '@/types';
-
-pdfjsLib.GlobalWorkerOptions.workerSrc = new URL(
- 'pdfjs-dist/build/pdf.worker.min.mjs',
- import.meta.url
-).toString();
+import './utils/setup-pdf-worker.js';
// Centralizing DOM element selection
export const dom = {
@@ -486,7 +482,7 @@ const createFileInputHTML = (options: FileInputOptions = {}) => {
return `